diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa8fe65 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +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: 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 + 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 + python -m build 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/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..2ab76a2 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) @@ -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/LAYOUT.md b/LAYOUT.md new file mode 100644 index 0000000..5244e63 --- /dev/null +++ b/LAYOUT.md @@ -0,0 +1,60 @@ +# Hermespace codespace layout + +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 + +``` +hermespace/ +├── PURPOSE.md / ABOUT.md / README.md / LAYOUT.md +├── src/hermespace/ # Python package +│ ├── 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) +│ ├── 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/ +│ ├── access/ # Access Workspace 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 / 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 | +| 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..4aafc26 100644 --- a/PURPOSE.md +++ b/PURPOSE.md @@ -1,89 +1,206 @@ # PURPOSE.md — Hermespace north star -**One line:** Hermespace is the **true external J-Space 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)**. 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)**. +## 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. + +**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. 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. --- -## 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 J-Space 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 | -|-------|-----|-----------| -| **J-Space 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 | +--- + +## 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. -## Anthropic → Hermespace map +### After a turn -| 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 | +`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. -## Standalone vs Cube-powered +### Across concurrent sessions -| Mode | Warehouse | Night path | -|------|-----------|------------| -| Standalone | Semantic + World | harvest → semantic/world | -| Heart / Center | `memory.cube` | harvest → seal_learning → CubeDream | +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 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 +via fail-soft imports (`cube_module`, `insight_module`). Neither is required. + +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. + +--- -## Non-goals +## Hermes Agent compatibility target -- 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 +Primary target: **Hermes Agent v0.20.0+**. + +Hermespace uses the current public plugin contracts: + +- `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` (harvest ≤10s, fail-open) +- `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 +``` + +--- -## Success metrics +## Product direction -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 +Deepen the **Access Engine**, not the feature count. -## Version posture +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. -Deepen the *environment* (observe + intervene + dream), not a second archive. -Plugin yaml + `__version__` move together. +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 d24db2d..9792fa9 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,15 +25,27 @@
-**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 [J-Space](https://github.com/anomalyco/j-space). Not a second agent runtime. A room inside Hermes that remembers everything. +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. --- ## 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 @@ -52,7 +64,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`. @@ -96,13 +110,13 @@ 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()`. ### 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 @@ -130,10 +144,11 @@ 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 | +| **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 — `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. | @@ -142,11 +157,20 @@ Alongside the world, Hermespace provides a desk for the current turn — FOA, du ### Quick start +```bash +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: + ```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 +187,17 @@ 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_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 | +| `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` | Harvest ≤10s fail-open, world leave, idle maintenance | +| `subagent_start/stop` | Track specialist lifecycle for runtime observability | --- @@ -184,7 +216,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 @@ -198,6 +230,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 @@ -211,7 +246,8 @@ $HERMESPACE_HOME/memory/hermespace/ | Command | Purpose | |---------|---------| | `hs world show\|enter\|leave\|evolve\|search\|archive-stats` | Persistent world | -| `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 | @@ -228,22 +264,28 @@ $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 | +| [`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/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-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/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 | +| [`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) | -| [`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 +294,19 @@ $HERMESPACE_HOME/memory/hermespace/ ## Repository Layout ```text -assets/ media (banners, diagrams) +LAYOUT.md codespace map (start here for structure) src/hermespace/ runtime package + 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 +docs/ access · 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/__init__.py b/__init__.py new file mode 100644 index 0000000..39cede7 --- /dev/null +++ b/__init__.py @@ -0,0 +1,28 @@ +"""Hermes source-repository plugin entry point. + +``hermes plugins install PabloTheThinker/hermespace --enable`` clones this +repository as one plugin. The checkout folder is often named ``hermespace``, +which would shadow ``src/hermespace``. Pop this shim from ``sys.modules`` +before importing the real ``register``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent +_SRC = _ROOT / "src" + +# 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)) + +# Parent-cwd / folder-name shadow: drop this file so src/hermespace can load. +if __name__ == "hermespace": + sys.modules.pop("hermespace", None) + +from hermespace.plugin import register + +__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/desktop_plugin/hermespace/README.md b/desktop_plugin/hermespace/README.md index d3646c5..6ded0eb 100644 --- a/desktop_plugin/hermespace/README.md +++ b/desktop_plugin/hermespace/README.md @@ -8,10 +8,10 @@ ## 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 | +| **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 41fc690..c68f966 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 */ @@ -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,31 +737,39 @@ 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', 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', @@ -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/docs/00-jspace-to-hermespace.md b/docs/00-jspace-to-hermespace.md deleted file mode 100644 index 0217c12..0000000 --- a/docs/00-jspace-to-hermespace.md +++ /dev/null @@ -1,45 +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=...)`. -Space still owns FOA competition and dual decode. - -## 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/CODEMAP.md b/docs/CODEMAP.md deleted file mode 100644 index 449828e..0000000 --- a/docs/CODEMAP.md +++ /dev/null @@ -1,61 +0,0 @@ -# Code map — where to edit - -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) - -## Layers (edit here first) - -``` -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 -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 -``` - -## Turn spine (L1) - -| Module | Role | -|--------|------| -| `workflow.py` | GATE→ENCODE→DESK→PLAN→DECODE→BROADCAST→SEAL + Cube/J-Space | -| `engine.py` | enter / update / seal desk | -| `desk.py` | ACTIVE.md model | -| `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 | -|--------|------| -| `cube_module.py` | `cube_beat` / `cube_pulse` / `seal_learning` / standalone | - -## Integration - -| Path | Role | -|------|------| -| `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 | - -## 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. diff --git a/docs/HERMESCUBE.md b/docs/HERMESCUBE.md deleted file mode 100644 index ff749fa..0000000 --- a/docs/HERMESCUBE.md +++ /dev/null @@ -1,71 +0,0 @@ -# HermesCube × Hermespace — heart / generator contract - -**HermesCube is the heart** (when installed). **Hermespace is the nervous FOA** — -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` - -``` -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 -``` - -## Authority - -| 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 | -| SemanticStore | Mirror / study | Local seal target | - -## Space adapter (`hermespace.cube_module`) - -```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 - seal_learning, - cube_inject, - strip_budget, -) -``` - -| Call | Use | -|------|-----| -| `ensure_heart()` | `Workbench.enter` / session start / pulse | -| `cube_beat(query, seals=, load=)` | Turn / `pre_llm_call` | -| `cube_pulse(agent_id=)` | `idle_tick` / `world_evolve` | -| `seal_learning(text)` | `remember_learning` / turn seal | -| `center_status()` | Doctor / desktop | - -Load tiers → strip chars: low 900 · mid 640 · high 420 · protect 280. - -## Functional J-Space - -See [00-jspace-to-hermespace.md](00-jspace-to-hermespace.md) and `hermespace.jspace`. - -```bash -hs jspace hold -t "deploy pipeline" -hs jspace report -hs jspace broadcast -hs cube status -hs cube beat -q "what do we believe about deploys?" -``` - -## Install both - -```bash -hermes plugins install PabloTheThinker/hermescube -# … then Hermespace install -./scripts/install_hermes.sh -``` - -Soft dependency: if Cube is missing, Space inject/seal/pulse use the standalone warehouse. diff --git a/docs/INDEX.md b/docs/INDEX.md index a8b034a..6b1d86f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,34 +1,25 @@ # 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 | +| [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 | +| [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..3974696 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# Documentation + +| Folder | Contents | +|--------|----------| +| [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 | +| [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:** [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/10-jspace-claude.md b/docs/access/10-claude-research.md similarity index 93% rename from docs/10-jspace-claude.md rename to docs/access/10-claude-research.md index e424a42..8998a43 100644 --- a/docs/10-jspace-claude.md +++ b/docs/access/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/access/27-environment.md similarity index 97% rename from docs/27-jspace-environment.md rename to docs/access/27-environment.md index f73ab6f..957df93 100644 --- a/docs/27-jspace-environment.md +++ b/docs/access/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/access/29-baars-changeux-anthropic.md b/docs/access/29-baars-changeux-anthropic.md new file mode 100644 index 0000000..f3159d6 --- /dev/null +++ b/docs/access/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/access/30-day-to-day-higher-order.md b/docs/access/30-day-to-day-higher-order.md new file mode 100644 index 0000000..ca2fc7f --- /dev/null +++ b/docs/access/30-day-to-day-higher-order.md @@ -0,0 +1,112 @@ +# 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) +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/access/31-anthropic-x-video-deep-dive.md b/docs/access/31-anthropic-x-video-deep-dive.md new file mode 100644 index 0000000..a00022b --- /dev/null +++ b/docs/access/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/access/32-hermespace-access-engine.md b/docs/access/32-hermespace-access-engine.md new file mode 100644 index 0000000..d3e6e87 --- /dev/null +++ b/docs/access/32-hermespace-access-engine.md @@ -0,0 +1,134 @@ +# 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) + +### 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 +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` +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/docs/access/34-access-engine.md b/docs/access/34-access-engine.md new file mode 100644 index 0000000..6e9c94e --- /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.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 +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/access/thesis-oew.md b/docs/access/thesis-oew.md new file mode 100644 index 0000000..2c707c8 --- /dev/null +++ b/docs/access/thesis-oew.md @@ -0,0 +1,48 @@ +# 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` | **`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 + +- `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) +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/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/architecture/CODEMAP.md b/docs/architecture/CODEMAP.md new file mode 100644 index 0000000..081657c --- /dev/null +++ b/docs/architecture/CODEMAP.md @@ -0,0 +1,65 @@ +# Code map — where to edit + +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:** [../access/34-jspace-engine.md](../access/34-jspace-engine.md) + +## Layers (edit here first) + +``` +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 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 +``` + +## Access Workspace package (`access/`) — L2 product + +| Module | Role | +|--------|------| +| **`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 `AccessEngine` | + +## Desk spine (L1) + +| Module | Role | +|--------|------| +| `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 | +| `inject.py` | Desk GWT strip | + +## Warehouse cable (L3 — optional) + +| Module | Role | +|--------|------| +| `cube_module.py` | Soft arterial strip / seal / pulse / room | + +## Integration + +| Path | Role | +|------|------| +| `hermes_bridge.py` | Plugin hooks — session / pre_llm / end | +| `hermes_plugin/` | Thin `register(ctx)` | +| `workbench.py` | Session pocket — enter / order / idle | +| `agent_api.py` | Dual-decode doors | + +## Rules of thumb + +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`. +5. Silent steps stay in model context only. +6. Prefer `from hermespace.jspace import …` over deep/compat paths. diff --git a/docs/architecture/HERMESCUBE.md b/docs/architecture/HERMESCUBE.md new file mode 100644 index 0000000..8b3b115 --- /dev/null +++ b/docs/architecture/HERMESCUBE.md @@ -0,0 +1,87 @@ +# HermesCube × Hermespace — heart / generator contract + +**HermesCube is the heart** (when installed). **Hermespace is the nervous FOA** — +and a **standalone workbench** when Cube is absent. + +Companion: [PabloTheThinker/hermescube](https://github.com/PabloTheThinker/hermescube) +North star: [PURPOSE.md](../PURPOSE.md) · Living assessment: +[33-living-memories-cube-world.md](../assessment/33-living-memories-cube-world.md) + +``` +Hermes Agent ──connect──► Hermespace (J-Space · FOA · OEW) + │ arteries / veins / pulse + ▼ + HermesCube (.cube · Cuboasis · hive · dream) +``` + +## Authority + +| Surface | With Cube | Standalone | +|---------|-----------|------------| +| `$HERMES_HOME/memories/memory.cube` | **Durable SoT** | n/a | +| 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 | + +## Space adapter (`hermespace.cube_module` **1.3**) + +```python +from hermespace.cube_module import ( + ensure_heart, + center_status, + 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, +) +``` + +| Call | Use | +|------|-----| +| `connect_agent(agent_id)` | Session start / `HermesBase.connect` | +| `ensure_heart()` | Create cube or standalone dirs | +| `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 | +| `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-map.md](../jspace/00-map.md) and `hermespace.jspace`. + +```bash +hs jspace hold -t "deploy pipeline" +hs jspace report +hs base lens +hs cube beat -q "what do we believe about deploys?" +``` + +## Install both + +```bash +hermes plugins install PabloTheThinker/hermescube +# … then Hermespace install +./scripts/install_hermes.sh +``` + +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/architecture/INSIGHT.md b/docs/architecture/INSIGHT.md new file mode 100644 index 0000000..0a444a3 --- /dev/null +++ b/docs/architecture/INSIGHT.md @@ -0,0 +1,23 @@ +# Hermes Insight × Hermespace — optional perceive_card cable + +**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 hermes_insight import HermesInsight +if hasattr(HermesInsight, "perceive_card"): + card = HermesInsight().perceive_card(goal, load=...) +``` + +| Rule | Behavior | +|------|----------| +| 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` / `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/docs/assessment/28-hermes-agent-jspace-assessment.md b/docs/assessment/28-hermes-agent-jspace-assessment.md new file mode 100644 index 0000000..a097b96 --- /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.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) + +--- + +## 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 — **done (v0.21)** + +| Item | Detail | Effort | +|------|--------|--------| +| 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 — **done (v0.21)** + +| Item | Detail | Effort | +|------|--------|--------| +| 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 + +| 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/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/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/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 98% rename from docs/23-everyday-ops.md rename to docs/ops/23-everyday.md index aa14e37..27d6897 100644 --- a/docs/23-everyday-ops.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/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/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..c61ff46 --- /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 | 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/day_in_life_oew.py b/experiments/day_in_life_oew.py new file mode 100644 index 0000000..90493c4 --- /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.access import AccessHub, AccessEnv + 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 = AccessEnv(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 = AccessHub(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 = AccessEnv(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 = 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 = 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") + 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 = AccessEnv(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 "Access Workspace" in (out.context or "") + and "Silent reasoning" not in out.report, + f"report_len={len(out.report)} ctx_has_hub={'Access Workspace' in (out.context or '')}", + ) + ) + + # --- 10. Night harvest consolidates silent → durable --- + 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) + 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 = 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( + _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 = 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} + 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/experiments/oew_eval.py b/experiments/oew_eval.py new file mode 100644 index 0000000..0b7184c --- /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.access import AccessEnv + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + results = [] + + # 1) Auto-park silent on material turn + 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", + 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 = 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 = 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 = 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") + 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 = 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() + 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 "Access Workspace" 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..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.20.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 e9fa35a..355fff3 100644 --- a/hermes_plugin/plugin.yaml +++ b/hermes_plugin/plugin.yaml @@ -1,14 +1,42 @@ +manifest_version: 1 name: hermespace -version: "0.20.0" +version: "0.25.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. + Production Access Engine for Hermes Agent v0.20+: session-scoped workspace, + bounded model context, native lifecycle telemetry, dual decode, and + standalone-safe persistence. Cube and Insight are optional soft-imports. author: Hermespace contributors kind: standalone 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 + - subagent_start + - subagent_stop +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 + - subagent_start + - subagent_stop +python_dependencies: + - numpy>=1.24,<3 homepage: https://github.com/PabloTheThinker/hermespace diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..355fff3 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,42 @@ +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. Cube and Insight are optional soft-imports. +author: Hermespace contributors +kind: standalone +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 + - subagent_start + - subagent_stop +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 + - subagent_start + - subagent_stop +python_dependencies: + - numpy>=1.24,<3 +homepage: https://github.com/PabloTheThinker/hermespace diff --git a/pyproject.toml b/pyproject.toml index 69d2abd..51920b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,23 @@ [project] name = "hermespace" -version = "0.20.0" -description = "True J-Space environment for Hermes Agent — external observable workspace, Cube heart, dual decode" +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/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/scripts/e2e_ops.sh b/scripts/e2e_ops.sh index b435339..563aa1c 100755 --- a/scripts/e2e_ops.sh +++ b/scripts/e2e_ops.sh @@ -2,12 +2,21 @@ # 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)}" 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 +24,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 406fcfd..98a57d2 100755 --- a/scripts/install_hermes.sh +++ b/scripts/install_hermes.sh @@ -1,74 +1,118 @@ #!/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 +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] [--yes] [--no-organs]" + exit 0 + ;; + *) + echo "hermespace: unknown installer option: $arg" >&2 + exit 2 + ;; + esac +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 install" +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" +mkdir -p "$HERMESPACE_HOME" "$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" +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" -# 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" - -# 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 -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 command -v hermes >/dev/null 2>&1; then - hermes plugins enable hermespace 2>/dev/null || true - echo " hermes plugins enable hermespace (attempted)" +if [[ "$INSTALL_DESKTOP" == "1" ]]; then + bash "$ROOT/scripts/install_desktop_plugin.sh" else - echo " hermes CLI not on PATH — enable later: hermes plugins enable hermespace" + echo " desktop plugin skipped" fi +# 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("install_front_door failed: " + str(out)) +PY + +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 _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") + 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") + + live_host = _verify_live_hermes_manager() + 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"), + "current_hermes_manager": live_host, + } + 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 563ed82..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` --- @@ -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 @@ -301,7 +302,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 +363,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 | @@ -373,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/skills/hermespace/references/plugin-hooks.md b/skills/hermespace/references/plugin-hooks.md index c2db72a..ebcc0dc 100644 --- a/skills/hermespace/references/plugin-hooks.md +++ b/skills/hermespace/references/plugin-hooks.md @@ -2,23 +2,31 @@ ## 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 | +| 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 | Harvest ≤10s fail-open + 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 +34,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 d1c31bf..88a63f0 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 Access Engine for Hermes agents.""" from __future__ import annotations -__version__ = "0.20.0" +__version__ = "0.25.0" from hermespace.desk import Desk from hermespace.engine import HermespaceEngine @@ -29,13 +29,18 @@ 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.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 import insight_module +from hermespace.hermes_base import HermesBase __all__ = [ "Desk", "HermespaceEngine", + "AccessEngine", + "ACCESS_ROLES", "Workflow", "TurnResult", "HermespaceInput", @@ -58,11 +63,14 @@ "WorldModel", "get_world", "world_context", - "JSpace", - "get_jspace", - "JSpaceEnv", + "AccessHub", + "get_access_hub", + "AccessEnv", "get_env", + "evaluate_material_turn", "cube_module", + "insight_module", + "HermesBase", "probe_environment", "environment_markdown", "build_inject_block", 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/access/engine.py b/src/hermespace/access/engine.py new file mode 100644 index 0000000..7f69c62 --- /dev/null +++ b/src/hermespace/access/engine.py @@ -0,0 +1,801 @@ +"""Access Engine — Hermespace's open-source access workspace for Hermes Agent. + +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 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 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.lens()) # what is on the agent's mind +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +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 = ( + "verbal_report", + "directed_modulation", + "internal_reasoning", + "flexible_broadcast", + "selectivity", +) + + +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.""" + + 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 + + @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.workspace_id) + + @property + def env(self): + from hermespace.access import AccessEnv + + return AccessEnv(agent_id=self.workspace_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( + desk_path=session_desk_path(self.agent_id, self.session_id) + ) + + # --- Access roles (live) ------------------------------------------------- + + def access_roles(self) -> dict[str, Any]: + """Report the five live Access Workspace roles.""" + 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.access.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, + "workspace_id": self.workspace_id, + "engine": "AccessEngine", + "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_access_from_warehouse + + out["phases"]["seed"] = seed_access_from_warehouse( + self.agent_id, + 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 + 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), + "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), + "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"AccessEngine connected {self.agent_id}: " + f"hub={out['gained']['access_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": "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": {}, + "world": {}, + "room": {}, + "warehouse": {}, + "ready": False, + "connected": bool(self._last_connect and self._last_connect.get("ok")), + "role": "Hermespace Access Workspace 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_lens": self.jlens_status(), + } + try: + js = self.hub + env = self.env + out["access"] = { + "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["access"] = {"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} + 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"] = { + "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["access"] + and int(out["access"].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 Access Workspace?""" + 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: + 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) + + 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). + + Intermediate steps never appear in the spoken answer unless explicitly + requested — 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) + out = Workflow(engine=self.desk_engine).run( + HermespaceInput( + message=message, + goal=goal or message[:200], + plan=list(plan or []), + 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"] = "AccessEngine" + 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_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("access", {}).get("oew_ok"), + "goal": out.goal, + "decision": out.decision, + "connected": bool(self._last_connect and self._last_connect.get("ok")), + "engine": "AccessEngine", + "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.access.loop import check_bound_report, park_spoken_intermediates + 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 []), + "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) + 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__ + + 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 + 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__} + + # --- optional open-weight activation lens (not required) ----------------- + + def jlens_status(self) -> dict[str, Any]: + """Optional open-weight activation lens — not required for Access Engine. + + 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, + "note": ( + "Hermes Agent typically has no weight access — " + "AccessEngine is Hermespace's Access Workspace." + ), + } + 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 +HermespaceAccessEngine = AccessEngine diff --git a/src/hermespace/access/env.py b/src/hermespace/access/env.py new file mode 100644 index 0000000..84f0742 --- /dev/null +++ b/src/hermespace/access/env.py @@ -0,0 +1,733 @@ +"""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. +""" + +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.atomic import atomic_write_text +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 +# (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 — encode → deliberate → report +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 access 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 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. + """ + + def __init__(self, agent_id: str = "hermes-agent") -> None: + self.agent_id = (agent_id or "hermes-agent").strip() + 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" + 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: + 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} + 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 point of view in the Access Workspace --- + + 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 = [ + "## 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):", + ] + 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) + # Rewrite silent steps (exact or substring — causal redirect) + pat = re.compile(re.escape(src), re.I) + 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.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) + return { + "ok": True, + "removed": removed, + "source": src, + "target": tgt, + "concept": concept.label(), + "sticky": True, + "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). + + 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) + body = (text or "").strip() + if silent: + 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]: + """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() + try: + 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) + return {"ok": True, "removed": len(removed), "items": removed, "sticky": True} + + # --- 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="access_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() + # Seed next turn's mid-band (counterfactual reflection → later silent thought) + try: + 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: + 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 (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. " + "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: + """Operator / bind protocol. Do not dump this essay onto the pre_llm inject. + + 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 "" + if high_load: + return ( + "### 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 = [ + "### 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 " + "(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]}") + 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) --- + + 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: Access Workspace 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="access_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=["access", "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:], + "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", + }, + } + + 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 = "", + 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 warehouse. + + Pass ``already_synced=True`` when the caller just ran + ``AccessHub.sync_from_desk`` to avoid a double hub rewrite. + """ + from hermespace.access.oew import run_oew_beat + + self.set_band("early") + 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 + 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 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 + + seal_learning( + seal_decision[:400], + entry_type="focus", + agent_id=self.agent_id, + source="access_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=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.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.access.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") -> AccessEnv: + return AccessEnv(agent_id=agent_id) diff --git a/src/hermespace/jspace.py b/src/hermespace/access/hub.py similarity index 81% rename from src/hermespace/jspace.py rename to src/hermespace/access/hub.py index bf5cce0..845294f 100644 --- a/src/hermespace/jspace.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. """ @@ -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, @@ -32,7 +33,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 +61,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 +92,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 +121,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 +130,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: + atomic_write_text(self.path, legacy.read_text(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 +164,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], @@ -169,10 +178,7 @@ def _load(self) -> JSpaceState: 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 --- @@ -237,7 +243,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 +273,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 +355,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" @@ -367,13 +373,37 @@ 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, + is_user_echo_copy, + ) + 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): + 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]): + 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( @@ -385,11 +415,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( @@ -401,13 +431,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, @@ -418,7 +448,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:] @@ -431,7 +461,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] @@ -489,7 +519,38 @@ 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 _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( + 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): @@ -508,7 +569,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: @@ -531,5 +597,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/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/access/oew.py b/src/hermespace/access/oew.py new file mode 100644 index 0000000..8e60118 --- /dev/null +++ b/src/hermespace/access/oew.py @@ -0,0 +1,292 @@ +"""Obligatory External Workspace — higher-order thinking orchestration. + +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. +""" + +from __future__ import annotations + +import os +import re +from typing import Any + +from hermespace.access.hub import AccessHub +from hermespace.access.protocol import ( + ProtocolVerdict, + evaluate_material_turn, + oew_enabled, +) + + +_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: + """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: AccessHub, + *, + 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 + + from hermespace.execute_focus import ( + derive_plan, + is_filler_step, + is_near_dup, + is_user_echo_copy, + 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 "" + decision = str(getattr(desk, "decision", "") or "") if desk is not None else "" + + for step in plan: + s = str(step).strip() + 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, + ): + chunks = [c.strip() for c in _STEP_SPLIT.split(msg) if c and c.strip()] + for ch in chunks[:4]: + 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]) + + # Near-duplicate collapse (prefix-stripped gist), not exact casefold only + existing = list(js.state.silent_steps) + for c in candidates: + 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) + if len(parked) >= 3 or len(js.state.silent_steps) >= 3: + break + + if not parked and min_steps > 0: + 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) + + 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 Access Workspace blocks.""" + if protect or high_load: + return 280 + return 640 + + +def run_oew_beat( + js: AccessHub, + 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 AccessEnv 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/access/protocol.py b/src/hermespace/access/protocol.py new file mode 100644 index 0000000..a1c87b3 --- /dev/null +++ b/src/hermespace/access/protocol.py @@ -0,0 +1,137 @@ +"""Obligatory External Workspace (OEW) protocol gate. + +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``. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any + + +def oew_enabled() -> bool: + """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 +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/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..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) @@ -227,11 +226,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/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/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 861d293..6af5af3 100644 --- a/src/hermespace/cli.py +++ b/src/hermespace/cli.py @@ -150,9 +150,62 @@ 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) - js = sub.add_parser("jspace", help="Functional J-Space: hold / report / broadcast / status") - js_sub = js.add_subparsers(dest="jspace_cmd", required=True) + # Functional Access Workspace (harness global workspace) + # Hermespace Access Engine (read / audit / shape / operate) + base = sub.add_parser( + "base", + 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 Access 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 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="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="GWT access roles (live)") + baseroles.add_argument("--agent-id", default="hermes-agent") + 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") + 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") + 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 warehouse/semantic") + baseh.add_argument("--agent-id", default="hermes-agent") + baseh.add_argument("--clear-silent", action="store_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") @@ -420,6 +473,21 @@ 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") + + 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") @@ -714,13 +782,69 @@ def main(argv: list[str] | None = None) -> int: return int(g.get("main", lambda: 1)()) return 2 - if args.cmd == "jspace": - from hermespace.jspace import JSpace + if args.cmd == "base": + from hermespace import AccessEngine + + aid = getattr(args, "agent_id", "hermes-agent") or "hermes-agent" + sid = getattr(args, "session_id", "main") or "main" + hb = AccessEngine(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, default=str)) + return 0 + 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 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( + 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 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 @@ -748,9 +872,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)) @@ -875,8 +999,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": @@ -1026,6 +1151,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, @@ -1033,7 +1159,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 @@ -1131,6 +1257,35 @@ 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 + + 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/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/context_surgery.py b/src/hermespace/context_surgery.py new file mode 100644 index 0000000..944fa80 --- /dev/null +++ b/src/hermespace/context_surgery.py @@ -0,0 +1,190 @@ +"""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 + + +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 "" + 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/cube_module.py b/src/hermespace/cube_module.py index 29552b7..1b52bed 100644 --- a/src/hermespace/cube_module.py +++ b/src/hermespace/cube_module.py @@ -1,14 +1,17 @@ """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.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 + Access Workspace + optional hive room 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 @@ -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.3" # Load → arterial char budgets (match Cube center when present). LOAD_STRIP_CHARS: dict[str, int] = { @@ -86,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", @@ -230,6 +233,72 @@ def cube_status() -> dict[str, Any]: return heart_status() +def hermes_memory_provider() -> str: + """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 + 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.""" + try: + return hermes_memory_provider() in {"hermescube", "cube"} + except Exception: + return False + + +def skip_cube_foa_strip() -> bool: + """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. + """ + try: + return cube_is_memory_provider() + except Exception: + return False + + +def cube_already_prefetched( + query: str = "", + *, + session_id: str = "", +) -> bool: + """Alias for ``skip_cube_foa_strip`` — config says Cube owns prefetch.""" + return skip_cube_foa_strip() + + def cube_beat( query: str = "", *, @@ -240,11 +309,44 @@ 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``, 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). """ + 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": "skipped", + "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 + try: from hermescube.center import beat @@ -267,7 +369,7 @@ def cube_beat( # Heart 1.0 / standalone fallback level = normalize_load(load, high_load=high_load) - out: dict[str, Any] = { + out = { "api_version": "1.0", "adapter": SPACE_CUBE_ADAPTER_VERSION, "mode": "standalone" if not cube_available() else "heart", @@ -336,6 +438,315 @@ 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 + Access Workspace; 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_access_from_warehouse( + agent_id: str = "hermes-agent", + *, + 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. + + 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.access import AccessHub + + js = AccessHub(agent_id=workspace_id or 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") + report["workspace_id"] = workspace_id or agent_id + return report + except Exception as e: + report["error"] = type(e).__name__ + 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", + *, + 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 + 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. + """ + out: dict[str, Any] = { + "ok": False, + "agent_id": agent_id, + "session_id": session_id, + "adapter": SPACE_CUBE_ADAPTER_VERSION, + "phases": {}, + "gained": {}, + "memories": [ + "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", + ], + } + 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: + 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 {} + 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), + "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), + "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']['access_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) ----------------------------------------- @@ -388,7 +799,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: @@ -531,20 +942,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/desk.py b/src/hermespace/desk.py index 4e7ee49..231763d 100644 --- a/src/hermespace/desk.py +++ b/src/hermespace/desk.py @@ -63,20 +63,27 @@ 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 + + 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)) 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) ) 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 @@ -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 e858d49..36b462f 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 Access Engine lives at ``hermespace.access.engine.AccessEngine``. +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 Access Engine). + + Use ``AccessEngine`` for connect / turn / lens / harvest. + """ def __init__(self, desk_path: Path | None = None) -> None: self.desk_path = desk_path or default_desk_path() @@ -50,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 new file mode 100644 index 0000000..ba1964f --- /dev/null +++ b/src/hermespace/execute_focus.py @@ -0,0 +1,530 @@ +"""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") +_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: + g = " ".join((goal or "").split()) + if not g: + return "untitled" + 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 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 + + +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: + """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) < 4: + 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( + items: Sequence[str], + *, + prefer_shorter: bool = False, +) -> list[str]: + """Collapse near-duplicate gists. Bind wins over prefixed copies.""" + 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 _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 + + +def shape_focus( + labels: Sequence[str], + *, + message: str = "", + goal: str = "", + plan: Sequence[str] | None = None, + cap: int = 4, +) -> list[str]: + """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] + + +_FILLER_ADJ = { + "a", + "an", + "the", + "short", + "brief", + "quick", + "small", + "simple", + "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(): + t = t[0].upper() + t[1:] + return t[:80] if len(t) <= 80 else t[:79].rstrip() + "…" + + +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 "" + 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: + 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 body == user: + return True + 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 + + +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()) + 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 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 = "", + 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): + 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 = "", + message: str = "", +) -> str: + steps = plan_or_derived(plan, message, goal) + if steps: + phrase = compress_action_phrase(steps[0]) + if ( + phrase + and not is_filler_step(phrase) + and not _is_bad_lead(phrase) + and not is_user_echo_copy(phrase, message, goal) + ): + return phrase[:40] + for raw in (say or "").splitlines(): + s = raw.strip().lstrip("-* ").lstrip("0123456789.) ") + if s and not _is_bad_lead(s): + return s[:160] + dec = (decision or "").strip() + 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 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)." + + +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 = "", + message: str = "", +) -> str: + """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()) + 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 _is_bad_lead(first): + rest = "\n".join(body.splitlines()[1:]).strip() + 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): + # Operator supplied a clean say — keep that action lead. + 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/gate.py b/src/hermespace/gate.py index 1694b59..4a75fea 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 @@ -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/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/grid/dream.py b/src/hermespace/grid/dream.py index 8768b4d..6f331e1 100644 --- a/src/hermespace/grid/dream.py +++ b/src/hermespace/grid/dream.py @@ -66,50 +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 — day unspoken thoughts → durable night memory - jspace_harvest: dict[str, Any] = {} + # Access Workspace dream harvest — single path via AccessEnv.dream_harvest + access_harvest: dict[str, Any] = {} try: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access import AccessEnv 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: + 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 - 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"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": len(harvested), "sealed": sealed_n} + 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 @@ -136,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/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/grid/viewport.py b/src/hermespace/grid/viewport.py index b322830..2ea9e7f 100644 --- a/src/hermespace/grid/viewport.py +++ b/src/hermespace/grid/viewport.py @@ -29,6 +29,63 @@ 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 = [] + 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: + 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}", + } + + def snapshot(agent_id: str = "default") -> dict[str, Any]: """Full read-only snapshot for viewport / API.""" desk_md = "" @@ -51,6 +108,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 +136,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, @@ -102,15 +162,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 +268,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 +288,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 new file mode 100644 index 0000000..c2f7a7f --- /dev/null +++ b/src/hermespace/hermes_base.py @@ -0,0 +1,15 @@ +"""HermesBase — thin product alias of ``AccessEngine``. + +Prefer ``from hermespace import AccessEngine`` for new code. +``HermesBase`` remains for day-to-day CLI / docs compatibility. +""" + +from __future__ import annotations + +from hermespace.access.engine import AccessEngine + + +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 d7f201f..ff913a5 100644 --- a/src/hermespace/hermes_bridge.py +++ b/src/hermespace/hermes_bridge.py @@ -13,8 +13,85 @@ def _truthy(name: str, default: str = "0") -> bool: return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"} +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, + silent_chain_strip, + strip_needed, +) + +HARVEST_BUDGET_S = 10.0 + + +def _bounded_context(text: str) -> str: + """Keep native hook output below 9k (Hermes spill is 10k).""" + + try: + cap = int(os.environ.get("HERMESPACE_PRE_LLM_MAX_CHARS", str(INJECT_HARD_CAP))) + except ValueError: + cap = INJECT_HARD_CAP + cap = max(2000, min(8999, 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 _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: - """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 @@ -29,10 +106,11 @@ 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() + eng = wb.workflow.engine desk = load_desk(eng.desk_path) if not desk.goal: desk.goal = "Hermes agent session workbench" @@ -59,6 +137,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) @@ -82,62 +168,78 @@ 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 via AccessEngine — world + hub seed (warehouse optional) + 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" + from hermespace import AccessEngine + + 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: AccessEngine · ok={conn.get('ok')}\n" + f"- warehouse: mode={gained.get('warehouse_mode')} (optional)\n" + 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" + 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: - 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" - ) - except Exception: - pass + try: + from hermespace.access import AccessHub + from hermespace.store import load_desk as _load_desk + from hermespace.world import WorldModel + + 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"- 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" + ) + except Exception: + pass block = ( - "## Hermespace workbench (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_heart}" - f"{block_extra_jspace}" - "- Pocket dimension online: park secondary goals, keep FOA tight, " - "user replies short; put operational detail in workspace context.\n" - "- API: `from hermespace import Workbench` · " - "`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" + f"{block_extra_connect}" + "- 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 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" ) - # 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" + 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: - pass + except Exception as exc: # noqa: BLE001 + logger.debug("session runtime start failed: %s", exc) return {"context": block} @@ -163,6 +265,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: @@ -170,32 +299,26 @@ 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() + # 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": ( - 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) - eng = HermespaceEngine() desk = load_desk(eng.desk_path) auto_order = _truthy("HERMESPACE_AUTO_ORDER", "0") @@ -219,7 +342,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 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 + + 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: @@ -237,27 +371,37 @@ 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 - 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) @@ -277,6 +421,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) @@ -285,150 +437,182 @@ 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 - block = build_inject_block(desk, max_chars=inject_cap, user_message=msg) - if not block.strip(): - return None - - 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 - - block += "\n\n" + pending_inject_block(agent_id) - except Exception: - pass + load_level = str((desk.load or {}).get("level") or "mid") + high_load = load_level in {"high", "protect"} + inject_cap = inject_budget(load_level) + # Session-start essay is observer-only. Never prepend it onto the inject. + _ = start_context - 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 - from hermespace.jspace import JSpace + 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 "") - if cube_block: - block += "\n\n" + cube_block - # Sync functional J-Space hub and append broadcast (model channel only) - js = JSpace(agent_id=agent_id) + # 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 "") + # 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 + + 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"), + } + 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 + from hermespace.access.oew import ensure_oew_env_default + + ensure_oew_env_default() + js = AccessHub(agent_id=access_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"), + "skipped": beat.get("skipped"), + "shrunk": beat.get("shrunk"), } - # Environment protocol — force externalization of silent thought try: - from hermespace.jspace_env import JSpaceEnv + from hermespace.access import AccessEnv - env = JSpaceEnv(agent_id=agent_id) - env.advance_turn( + env = AccessEnv(agent_id=access_id) + env_meta = env.advance_turn( user_message=msg, desk=desk, cube_strip=cube_block, report=desk.say or "", + material=True, + already_synced=True, ) - 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_env"] = { + if env_meta.get("report"): + desk.say = str(env_meta["report"]) + # 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), + "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["access_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"), } + desk.meta["user_reply_hint"] = (desk.say or "")[:240] except Exception: - pass + desk.meta["access"] = { + "hub_n": len(js.state.hub), + "focus_n": len(js.state.focus), + "mode": js.state.mode, + } 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) + 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) 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 + 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: + organ = insight_strip or cube_block + if organ: + parts.append(organ) + if load_level == "low": try: - from hermespace import ops as ops_mod + from hermespace.self_model import format_self_trace, read_self_trace + from hermespace.access import AccessHub - block += "\n" + ops_mod.compact_status( - agent_id=agent_id if agent_id != "hermes-agent" else "default" + parts.append( + format_self_trace( + read_self_trace(AccessHub(agent_id=access_id)), + for_inject=True, + ) ) except Exception: pass + 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 = "" + try: eng.episodes.write( f"broadcast reason={reason} session={sid[:12]} high={high_load}", @@ -438,23 +622,254 @@ def on_pre_llm_call( except Exception: pass - return {"context": block} + result: dict[str, str] = {"context": _bounded_context(block)} + if user_hint: + result["user_reply_hint"] = user_hint + return result + + +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 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 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 + + 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 + + runtime.subagent(session_id or task_id, agent_id=agent_id, started=False) + except Exception: + pass + + +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.""" -def on_session_end(**kwargs: Any) -> None: 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.hermes_runtime import runtime + + if not runtime.finalize(sid, agent_id=agent_id): + return + except Exception: + pass + try: from hermespace.world import WorldModel - agent_id = os.environ.get("HERMESPACE_AGENT_ID", "hermes-agent") - WorldModel(agent_id=agent_id).leave("session ended") + + WorldModel(agent_id=agent_id).leave("session finalized") + except Exception as exc: # noqa: BLE001 + logger.debug("world finalize failed: %s", exc) + 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 + + 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: + """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 + + +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.workbench import Workbench + from hermespace.access import AccessEnv + from hermespace.access.engine import workspace_id - 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) + 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/hermes_enable.py b/src/hermespace/hermes_enable.py new file mode 100644 index 0000000..709248e --- /dev/null +++ b/src/hermespace/hermes_enable.py @@ -0,0 +1,274 @@ +"""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)} + + +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/hermes_runtime.py b/src/hermespace/hermes_runtime.py new file mode 100644 index 0000000..f7c81d2 --- /dev/null +++ b/src/hermespace/hermes_runtime.py @@ -0,0 +1,253 @@ +"""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 + shared_hub: 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 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 + 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/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 new file mode 100644 index 0000000..500c12b --- /dev/null +++ b/src/hermespace/insight_module.py @@ -0,0 +1,143 @@ +"""Hermes Insight adapter — thin soft-import, never required. + +Insight stays a standalone package. This module is the cable only: + + from hermes_insight import HermesInsight + hasattr(HermesInsight, "perceive_card") + HermesInsight().perceive_card(goal, load=...) + +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 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. +""" + +from __future__ import annotations + +from typing import Any + +SPACE_INSIGHT_ADAPTER_VERSION = "1.1" +INSIGHT_CARD_CHARS = 400 + + +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: + from hermes_insight import HermesInsight + + return hasattr(HermesInsight, "perceive_card") + except Exception: + return False + + +def insight_status() -> dict[str, Any]: + """Feature-detect ``perceive_card`` only — never required.""" + 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 + from hermes_insight import HermesInsight + + 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 insight_card( + goal: str, + *, + load: str | float | None = None, + high_load: bool = False, + max_chars: int = INSIGHT_CARD_CHARS, +) -> dict[str, Any]: + """Call ``HermesInsight().perceive_card`` and return only that card. + + Soft-fail if Insight is absent or ``perceive_card`` is missing. + Never calls ``recall`` / ``perceive`` / ``plan`` / ``insight_beat``. + Never formats a lattice dump. + """ + out: dict[str, Any] = { + "ok": True, + "adapter": SPACE_INSIGHT_ADAPTER_VERSION, + "mode": "missing", + "card": "", + "writeback": {}, + "required": False, + } + if _high_or_protect(load, high_load=high_load): + out["mode"] = "skipped" + out["skipped"] = "high_load" + return out + if not (goal or "").strip(): + out["mode"] = "skipped" + out["skipped"] = "empty" + return out + try: + from hermes_insight import HermesInsight + except Exception: + out["mode"] = "missing" + out["skipped"] = "not_installed" + return out + if not hasattr(HermesInsight, "perceive_card"): + out["mode"] = "no_perceive_card" + out["skipped"] = "no_perceive_card" + return out + try: + rec = HermesInsight().perceive_card((goal or "").strip(), load=load) + except Exception as e: + out["mode"] = "soft_fail" + 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), + "writeback": writeback, + } + ) + return out 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/jspace/__init__.py b/src/hermespace/jspace/__init__.py new file mode 100644 index 0000000..71d2afa --- /dev/null +++ b/src/hermespace/jspace/__init__.py @@ -0,0 +1,65 @@ +"""Deprecated shim — prefer ``hermespace.access`` (Access Workspace). + +Old Anthropic-inspired product name removed to keep Hermespace's own brand. +""" + +from __future__ import annotations + +from hermespace.access import * # noqa: F403 +from hermespace.access import ( + ACCESS_ROLES, + AccessEngine, + AccessEnv, + AccessHub, + AUDIT_LEXICON, + BANDS, + HermespaceAccessEngine, + HUB_CAP, + LensHit, + ProtocolGate, + ProtocolVerdict, + WorkspaceConcept, + auto_park_silent, + evaluate_material_turn, + filter_ablated, + get_access_hub, + get_env, + oew_enabled, + run_oew_beat, + shape_report, +) + +# Legacy aliases (do not use in new code) +JSpace = AccessHub +JSpaceEnv = AccessEnv +JSpaceEngine = AccessEngine +get_jspace = get_access_hub +HermespaceJSpaceEngine = HermespaceAccessEngine + +__all__ = [ + "ACCESS_ROLES", + "AccessEngine", + "AccessEnv", + "AccessHub", + "AUDIT_LEXICON", + "BANDS", + "HermespaceAccessEngine", + "HUB_CAP", + "LensHit", + "ProtocolGate", + "ProtocolVerdict", + "WorkspaceConcept", + "auto_park_silent", + "evaluate_material_turn", + "filter_ablated", + "get_access_hub", + "get_env", + "oew_enabled", + "run_oew_beat", + "shape_report", + "JSpace", + "JSpaceEnv", + "JSpaceEngine", + "get_jspace", + "HermespaceJSpaceEngine", +] diff --git a/src/hermespace/jspace_env.py b/src/hermespace/jspace_env.py index 8528b27..5551dd7 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. -""" +"""Deprecated shim — use ``hermespace.access_env`` or ``hermespace.access``.""" 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.access_env import * # noqa: F403 +from hermespace.access_env import AUDIT_LEXICON, BANDS, AccessEnv, LensHit, get_env -def get_env(agent_id: str = "hermes-agent") -> JSpaceEnv: - return JSpaceEnv(agent_id=agent_id) +__all__ = ["AUDIT_LEXICON", "BANDS", "AccessEnv", "LensHit", "get_env"] 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/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/neural_space.py b/src/hermespace/neural_space.py index 154aa30..51dfdef 100644 --- a/src/hermespace/neural_space.py +++ b/src/hermespace/neural_space.py @@ -82,23 +82,39 @@ 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 ( + 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, modality=slot.modality.value, source="desk", ) - if 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") @@ -127,18 +143,35 @@ 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}") bodies.add(v) - desk.concepts = new_concepts[-12:] - desk.focus = [f"[{t.modality}|{t.energy:.2f}] {t.text}" for t in ignited] + 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, + goal=desk.goal, + plan=desk.plan, + ) snap = self.field.snapshot() + 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/src/hermespace/ops.py b/src/hermespace/ops.py index a2e2aa2..f5d2459 100644 --- a/src/hermespace/ops.py +++ b/src/hermespace/ops.py @@ -2,14 +2,13 @@ from __future__ import annotations -import os import socket import time from pathlib import Path 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 +21,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 +29,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: @@ -76,24 +81,39 @@ 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: + 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() @@ -134,15 +154,42 @@ 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" - add(plug.exists(), "hermes_plugin_link", str(plug)) + 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)) - 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 + # 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 @@ -153,12 +200,29 @@ def add(ok: bool, name: str, detail: str = "") -> None: "boundary_default_deny", "viewport_html", "version", - "jspace", + "access", + "package_import", + "hermes_runtime", + "hermes_plugin", + "hermes_skill", + "plugins_enabled", } ) + 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, - "all_green": all(c["ok"] for c in checks), + "integration_ok": integration_ok, + "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()), @@ -175,10 +239,18 @@ 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"): - out.append("Install Hermes plugin: ./scripts/install_hermes.sh && hermes plugins enable hermespace") + if not by.get("hermes_plugin", {}).get("ok"): + 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") + 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 @@ -190,6 +262,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 +297,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 @@ -248,7 +322,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/paths.py b/src/hermespace/paths.py index 1317193..2d37991 100644 --- a/src/hermespace/paths.py +++ b/src/hermespace/paths.py @@ -3,21 +3,30 @@ 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. 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() @@ -38,6 +47,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..5cbc8f6 --- /dev/null +++ b/src/hermespace/plugin.py @@ -0,0 +1,144 @@ +"""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_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, + ) + + hooks = { + "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, + "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 (OSError, RuntimeError, ValueError) 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/pulse.py b/src/hermespace/pulse.py index 36ce281..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 @@ -194,9 +196,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 +464,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 +480,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 +497,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, } @@ -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) 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/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/streams.py b/src/hermespace/streams.py index 211beda..b38b660 100644 --- a/src/hermespace/streams.py +++ b/src/hermespace/streams.py @@ -57,8 +57,9 @@ 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 / 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 bundle.text.append(Slot(f"lang_stream: {gist[:160]}", Modality.VERBAL, sal)) @@ -83,7 +84,7 @@ def encode_stimulus(user_message: str, *, goal_hint: str = "") -> StreamBundle: Slot("production: prepare verbal report (decode path)", Modality.EXEC, 0.8) ) - if goal_hint: + if goal_hint and gist_key(goal_hint) != gist_key(msg): bundle.text.append(Slot(f"intention: {goal_hint[:100]}", Modality.VERBAL, 0.65)) return bundle @@ -96,23 +97,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 +121,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/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/workbench.py b/src/hermespace/workbench.py index 4f4c964..f0b2329 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, @@ -41,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) @@ -72,7 +76,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,14 +117,25 @@ 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) -> 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 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). + """ 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 @@ -125,7 +149,7 @@ def enter(self) -> 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 @@ -138,19 +162,54 @@ def enter(self) -> 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=access_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_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_access_from_warehouse( + self.agent_id, + query="", + session_id=self.session_id, + room=room, + workspace_id=access_id, + ) + 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["access"] = { + **(self.state.meta.get("access") 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"] = { @@ -160,20 +219,43 @@ def enter(self) -> 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 - 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() @@ -221,7 +303,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 @@ -259,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: @@ -274,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, @@ -282,58 +375,26 @@ def receive_order( seal=seal, tags=["workbench", "order"], ) + # 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) - - # 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("access") 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["access"] = { + "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__} @@ -355,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() @@ -372,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 d88a0c5..45c0748 100644 --- a/src/hermespace/workflow.py +++ b/src/hermespace/workflow.py @@ -88,14 +88,52 @@ def run(self, inp: HermespaceInput | dict[str, Any]) -> HermespaceOutput: return out # 2–5 desk - g = payload.goal or existing.goal or msg[:200] + from hermespace.execute_focus import ( + derive_plan_steps, + message_is_new_goal, + plan_or_derived, + ) + + 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 = payload.plan or existing.plan or ["execute"] - 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 payload.force or not existing.goal or payload.goal: + 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 + + 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 or msg_goal: desk = self.engine.enter( goal=g, concepts=list(cons or []), @@ -136,101 +174,176 @@ 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: fabric_snap = {"error": type(exc).__name__} - # 5d Cube beat + functional J-Space environment (soft — 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 - from hermespace.jspace_env import JSpaceEnv + 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 + 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 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"), "load_level": beat.get("load_level"), "chars": len(cube_block), + "skipped": beat.get("skipped"), + "shrunk": beat.get("shrunk"), } - js = JSpace(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) - # 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 = AccessEnv(agent_id=access_id) + # already_synced: avoid double hub rewrite inside advance_turn env_meta = env.advance_turn( user_message=msg, desk=desk, cube_strip=cube_block, report=desk.say or "", seal_decision=desk.decision if payload.seal else "", + material=True, + already_synced=True, ) - jspace_meta["band"] = env_meta.get("band") - jspace_meta["audit_alerts"] = env_meta.get("audit_alerts") - desk.meta["jspace"] = jspace_meta + # 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 "") + access_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"] = 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"), } 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) - if cube_block: - block = (block + "\n\n" + cube_block).strip() try: - from hermespace.jspace import JSpace - from hermespace.jspace_env import JSpaceEnv - - js = JSpace(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) - 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() + desk.refresh_focus(msg) + save_desk(desk, self.engine.desk_path) except Exception: pass - report = (desk.say or "").strip() - # Summon: if user asked for workspace report, surface it in Report channel + + # 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, + silent_chain_strip, + ) + + 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.jspace import JSpace - from hermespace.jspace_env import JSpaceEnv + from hermespace.access import AccessHub, AccessEnv + from hermespace.access.engine import workspace_id + from hermespace.access.loop import bound_protocol_lines - js = JSpace(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) + 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. 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() + 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 + + report = shape_execute_report( + report, + goal=desk.goal, + plan=list(desk.plan or []), + say=payload.say, + decision=desk.decision, + message=msg, + ) + desk.say = report except Exception: pass @@ -272,8 +385,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..3350e70 100644 --- a/src/hermespace/world.py +++ b/src/hermespace/world.py @@ -1,10 +1,12 @@ """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. -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. @@ -22,6 +24,7 @@ from pathlib import Path from typing import Any +from hermespace.atomic import atomic_write_text from hermespace.paths import state_dir @@ -118,7 +121,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) @@ -242,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: @@ -295,7 +299,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 @@ -312,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] @@ -366,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 @@ -829,7 +879,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_access_engine.py b/tests/test_access_engine.py new file mode 100644 index 0000000..12a02d7 --- /dev/null +++ b/tests/test_access_engine.py @@ -0,0 +1,121 @@ +"""AccessEngine — unified open-source J-space for Hermes Agent.""" + +from __future__ import annotations + +import os +import tempfile +import unittest + + +class TestAccessEngine(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, 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 = AccessEngine(agent_id=aid) + out = eng.connect(query="decode") + self.assertTrue(out.get("ok"), msg=out) + 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("access_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"], "AccessEngine") + self.assertIn("hub_pressure", st["metrics"]) + + def test_chain_silent_not_in_user_report(self) -> None: + from hermespace import AccessEngine + + 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() + 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 AccessEngine + + 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) + 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 AccessEngine + + 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" + ) + self.assertFalse(trivial.get("material")) + self.assertTrue(material.get("material"), msg=material) + + def test_turn_dual_decode(self) -> None: + from hermespace import AccessEngine + + eng = AccessEngine(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("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, AccessEngine + + self.assertTrue(issubclass(HermesBase, AccessEngine)) + 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_jspace_env.py b/tests/test_access_env.py similarity index 78% rename from tests/test_jspace_env.py rename to tests/test_access_env.py index f2d2b6a..1828373 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) @@ -42,12 +42,14 @@ 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("Access lens", md) + self.assertNotIn("J-Lens readout", md) + self.assertNotIn("Operator 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 +58,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 +69,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 +81,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 +95,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 +106,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 +117,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 +148,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 56% rename from tests/test_jspace_cube.py rename to tests/test_access_hub.py index 9974761..54c41b2 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)) @@ -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,6 +142,64 @@ def test_seal_and_inject(self) -> None: self.assertIn("block", beat) self.assertEqual(beat.get("mode"), "standalone") + 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, + skip_cube_foa_strip, + ) + + self.assertTrue(skip_cube_foa_strip()) + self.assertTrue(cube_already_prefetched("deploy")) + 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_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") + 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("HERMES_HOME", None) + def test_strip_budget(self) -> None: from hermespace.cube_module import normalize_load, strip_budget @@ -177,7 +241,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 +251,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,11 +268,47 @@ 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) +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() 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() diff --git a/tests/test_access_protocol.py b/tests/test_access_protocol.py new file mode 100644 index 0000000..0aed2f1 --- /dev/null +++ b/tests/test_access_protocol.py @@ -0,0 +1,77 @@ +"""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.access.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.access.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_default_on_blocks_incomplete(self) -> None: + os.environ.pop("HERMESPACE_OEW", None) # default ON + from hermespace.access.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.access.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.access.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.access import ( + AccessHub, + AccessEnv, + ProtocolGate, + evaluate_material_turn, + ) + + self.assertTrue(callable(evaluate_material_turn)) + self.assertTrue(ProtocolGate) + self.assertTrue(AccessHub) + self.assertTrue(AccessEnv) + + +if __name__ == "__main__": + unittest.main() 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() 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() diff --git a/tests/test_cognition_chain.py b/tests/test_cognition_chain.py new file mode 100644 index 0000000..33f8bb7 --- /dev/null +++ b/tests/test_cognition_chain.py @@ -0,0 +1,130 @@ +"""Two-turn silent chain + live-goal refresh. 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_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 + + 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) + 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() + 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) + 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 (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 (prior)", 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_connect_room.py b/tests/test_connect_room.py new file mode 100644 index 0000000..fd48652 --- /dev/null +++ b/tests/test_connect_room.py @@ -0,0 +1,122 @@ +"""Cube-centered connect — agent gains world + Access Workspace + 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("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["access"].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_access_from_warehouse + from hermespace.access import AccessHub + + 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_access_from_warehouse(aid, room=room) + self.assertTrue(rep.get("ok")) + self.assertGreaterEqual(int(rep.get("enriched_peers") or 0), 1) + 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) + 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_context_surgery.py b/tests/test_context_surgery.py new file mode 100644 index 0000000..f52727a --- /dev/null +++ b/tests/test_context_surgery.py @@ -0,0 +1,271 @@ +"""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) + 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 (prior)", 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) + 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_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 (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) + + 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_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() diff --git a/tests/test_desk_quality.py b/tests/test_desk_quality.py new file mode 100644 index 0000000..b8ea2de --- /dev/null +++ b/tests/test_desk_quality.py @@ -0,0 +1,220 @@ +"""Desk quality cut — empty-say Lyra turn, FOA collapse, smoke shadow, lens title.""" + +from __future__ import annotations + +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." + + +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() + 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, 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. + 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("lang_stream:", low) + self.assertNotIn("→ a — proceed", low) + self.assertNotIn("a — proceed", low) + self.assertNotEqual(low, "execute") + self.assertNotEqual(low, LIVE_MSG.casefold()) + first_clause = "write a short readme for the auth fix" + self.assertNotEqual(low, first_clause) + self.assertEqual(line1, "Write the README") + self.assertEqual(line1, next_action_line(message=LIVE_MSG, say="", 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() + if not focus: + focus = list(desk.focus or []) + self.assertLessEqual(len(focus), 4) + _assert_pairwise_distinct(self, focus, "FOA") + bodies = _verbal_bodies(focus) + bind_n = sum(1 for x in focus if str(x).casefold().startswith("[bind") or " | " in str(x)) + 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) + + neural_focus = [] + if isinstance(out.meta, dict): + 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 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") + _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 + 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.assertEqual(line1, "Write the README") + + def test_access_lens_title(self) -> None: + from hermespace.access import AccessEnv + + md = AccessEnv(agent_id="lens-title").lens_markdown() + self.assertIn("## Access lens (harness workspace)", md) + self.assertNotIn("J-Lens readout", md) + self.assertNotIn("Operator lens", 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() 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() 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_hermes_base.py b/tests/test_hermes_base.py new file mode 100644 index 0000000..02604e5 --- /dev/null +++ b/tests/test_hermes_base.py @@ -0,0 +1,72 @@ +"""HermesBase facade — Hermes base as functional Access Workspace.""" + +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("connect", st["ops"]) + self.assertIn("lens", st["ops"]) + self.assertIn("room", st) + self.assertEqual(st.get("engine"), "AccessEngine") + + 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 + + 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_access_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() diff --git a/tests/test_hermes_bridge.py b/tests/test_hermes_bridge.py index f39e8f5..9ee1b3b 100644 --- a/tests/test_hermes_bridge.py +++ b/tests/test_hermes_bridge.py @@ -10,24 +10,49 @@ 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) self.assertIn("context", r) - self.assertIn("Workbench", r["context"]) - desk = load_desk() + self.assertTrue( + "Access Engine" in r["context"] or "Workbench" in r["context"] + ) + 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( - 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) - on_session_end(session_id="bridge-test") + # Dual-decode hint for hosts that only get context + self.assertTrue( + "user_reply_hint" in inj + 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, + interrupted=False, + ) + on_session_finalize(session_id="bridge-test") if __name__ == "__main__": unittest.main() 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() diff --git a/tests/test_insight_module.py b/tests/test_insight_module.py new file mode 100644 index 0000000..de9d9b3 --- /dev/null +++ b/tests/test_insight_module.py @@ -0,0 +1,285 @@ +"""Insight cable — perceive_card only, fail-soft, 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 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_lat, situation, **kwargs): + self._perceive_calls["n"] += 1 + return { + "card": "UNBOUNDED LATTICE DUMP " + ("x" * 800), + "usable": True, + "matches": [{"title": "should-not-appear"}], + } + + def plan(self_lat, situation, **kwargs): + self._plan_calls["n"] += 1 + return {"steps": [{"title": "should-not-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 tearDown(self) -> None: + self._mod.stop() + self._td.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + + 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, "recall": 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"}]} + + 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" + self._mod = mock.patch.dict(sys.modules, {"hermes_insight": pkg}) + self._mod.start() + + def tearDown(self) -> None: + self._mod.stop() + self._td.cleanup() + os.environ.pop("HERMESPACE_HOME", None) + + def test_appends_bounded_perceive_card_only(self) -> None: + from hermespace.insight_module import insight_available, insight_card, insight_status + + 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.assertIn("shared-token", rec.get("card") or "") + self.assertLessEqual(len(rec.get("card") or ""), 400) + 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 + + 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) + + 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 + + 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.assertLessEqual(len(rec.get("card") or ""), 400) + self.assertTrue((rec.get("card") or "").endswith("...")) + + +class TestWorkflowDoesNotHangInsight(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_does_not_inject_insight_strip(self) -> None: + from hermespace.io_contract import HermespaceInput + from hermespace.workflow import Workflow + + 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": "protect", "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, + ) + mocked.assert_called() + 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__": + unittest.main() 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_oew_causal.py b/tests/test_oew_causal.py new file mode 100644 index 0000000..0749bb7 --- /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.access import AccessEnv + + desk = Desk( + goal="Fix auth timeout", + plan=["repro", "patch TTL", "verify"], + decision="A — patch TTL", + say="Patching session TTL.", + ) + env = AccessEnv(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.access import AccessEnv + + 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") + 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.access import AccessEnv + + 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.access import AccessEnv + + 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") + 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.access import AccessEnv + + 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 + 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("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("Access Workspace", out.context or "") + + +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..b5fc90b --- /dev/null +++ b/tests/test_plugin_contract.py @@ -0,0 +1,164 @@ +"""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) + 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) + + 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.assertNotIn("J-Lens readout", result["context"]) + self.assertLessEqual(len(result["context"]), 2800) + + 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_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", + "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", + "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 + + 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() 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() 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() +