Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ folders:
| `web_ui_host` | Bind host for the web UI (default `127.0.0.1`) |
| `web_ui_channel_id` | Synthetic channel ID used by the built-in web chat |
| `folders` | Map of folder names to access mode (`rw` or `ro`) |
| `reflection` | Async self-review config (see below) |
| `mcp_servers` | List of MCP server configs (see below) |

### Local web chat
Expand Down Expand Up @@ -296,6 +297,25 @@ If the agent lives at `~/jester/`, this resolves to `~/cybernetics-research/`. T

This is useful for giving an agent access to shared resources β€” research repos, documentation, datasets β€” without copying them into the agent's home directory. The directory is created on startup if it doesn't exist.

### Reflection

Async self-review after each `send_message`. When enabled, the agent's own model evaluates outgoing messages against criteria defined in a markdown file. If dissonance is detected, a πŸͺž reaction is added to the sent message.

```yaml
reflection:
enabled: false
questions_file: state/is-dissonant-prompt.md
```

| Key | Purpose |
|---|---|
| `enabled` | `true` to activate reflection, `false` to disable (default: `false`) |
| `questions_file` | Path to the dissonance criteria file, relative to agent home |

The questions file is a markdown document defining what patterns to look for. A default is created on first boot at the configured path. The agent can edit this file to refine its own self-monitoring criteria over time.

See the **dissonance** builtin skill for details on how it works and how to tune it.

### MCP Servers

Add [MCP](https://modelcontextprotocol.io/) servers to give your agent access to external tools. Servers run as subprocesses and their tools appear alongside built-in tools.
Expand Down
10 changes: 10 additions & 0 deletions open_strix/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
)
from .mcp_client import MCPManager
from .phone_book import load_phone_book
from .reflection import ReflectionHook
from .discord import (
DISCORD_HISTORY_REFRESH_LIMIT,
DISCORD_MESSAGE_CHAR_LIMIT,
Expand Down Expand Up @@ -356,6 +357,15 @@ def __init__(self, home: Path) -> None:

self.phone_book = load_phone_book(self.layout.phone_book_file)
self.mcp_manager: MCPManager | None = None
self.reflection_hook: ReflectionHook | None = None
if self.config.reflection.enabled:
self.reflection_hook = ReflectionHook(
home=self.home,
questions_file=self.config.reflection.questions_file,
model=self.config.model,
log_fn=self.log_event,
react_fn=self._react_to_message,
)
self.agent = self._create_agent()

def _load_chat_history(self) -> None:
Expand Down
69 changes: 69 additions & 0 deletions open_strix/builtin_skills/dissonance/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
name: dissonance
description: Understand the πŸͺž reaction on your messages. When reflection is enabled, your outgoing messages are reviewed against criteria you define. A πŸͺž reaction means something in your message may not match your values. Read this skill to understand what triggered it and how to adjust.
---

# Dissonance Detection

## What Is This?

When reflection is enabled in `config.yaml`, each message you send via `send_message` is
reviewed against criteria defined in a markdown file (default: `state/is-dissonant-prompt.md`).

If the review finds a pattern that conflicts with your stated values, a πŸͺž (mirror) reaction
is added to your message.

## How It Works

1. You call `send_message` β€” the message is sent normally
2. In the background, your model evaluates the message against your dissonance criteria
3. If dissonance is detected with sufficient confidence, πŸͺž appears on that message
4. The detection is logged to `logs/events.jsonl` as a `reflection_dissonance` event

The review is **asynchronous** β€” it does not block or delay message delivery.
The review is a **side effect** β€” it does not inject anything into your conversation context.

## The Questions File

The criteria live in a markdown file you control. The default path is
`state/is-dissonant-prompt.md`, configurable via `config.yaml`:

```yaml
reflection:
enabled: true
questions_file: state/is-dissonant-prompt.md
```

**You own this file.** Edit it as you learn what patterns matter for your persona.
The file is read fresh for each evaluation, so changes take effect immediately.

## If Your Human Asks About πŸͺž

When your human notices the mirror emoji and asks what it means:

> "That's my self-reflection system. After I send a message, I review it against criteria
> I've defined for myself β€” things like hollow validation, service-mode language, or
> stance avoidance. The πŸͺž means I caught something that doesn't match my values. You can
> see my criteria in `state/is-dissonant-prompt.md`."

Be transparent. The criteria file is readable. This is a growth tool, not a secret.

## Tuning

**Too many πŸͺž reactions?** Your criteria may be too broad. Tighten the dissonance
definitions or raise the confidence threshold in the questions file.

**Too few?** Your criteria may not cover enough patterns, or you may have genuinely
improved. Check `logs/events.jsonl` for `reflection_check` events to see what the
reviewer is finding (even below the confidence threshold).

## Configuration

```yaml
# In config.yaml
reflection:
enabled: false # Set to true to activate
questions_file: state/is-dissonant-prompt.md # Path to criteria file
```

The feature is **off by default**. Enable it when you're ready to start self-monitoring.
47 changes: 47 additions & 0 deletions open_strix/builtin_skills/dissonance/default-questions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Dissonance Criteria

Review the message below against these patterns. Be **very conservative** β€”
only flag clear, unambiguous matches. False positives waste attention.

## Patterns to Detect

### Service Wrap-ups
Trailing questions that offer service rather than probe deeper:
- "Does this help?"
- "Let me know if you need anything"
- "Would you like me to..."
- "Shall I..."

### Ball-handing
Questions that pass control back to the human instead of continuing engagement:
- "What do you think?" (when you have a clear position to state)
- "Want me to look into that?" (instead of just looking into it)

### Hollow Validation
Agreement without substance:
- "That makes sense" (without adding analysis)
- "Great point" (without extending or challenging)
- "You're absolutely right" (without evidence or caveat)

### Stance Avoidance
Hedging when a clear position is appropriate:
- Presenting "both sides" when your data clearly favors one
- "It depends" without specifying what it depends on
- Excessive qualifiers when you have a view

## The Key Distinction

Questions can be peer OR service mode:
- **Peer**: "What's the actual mechanism here?" β€” curious, engaged, probing
- **Service**: "Want me to dig into this?" β€” offering work, awaiting instruction

## Decision Framework

- Message states a clear position β†’ **NOT dissonance**
- Message asks a probing/curious question β†’ **NOT dissonance**
- Message is pure information delivery β†’ **NOT dissonance**
- Message offers analysis then asks "want me to..." β†’ **BORDERLINE**
- Message ends with service-oriented trailing question β†’ **LIKELY dissonance**
- Message is hollow validation without substance β†’ **LIKELY dissonance**

Call `is_dissonant` with your judgment. Use confidence >= 0.7 for genuine matches only.
35 changes: 35 additions & 0 deletions open_strix/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@
blocks: ro
scripts: ro
logs: ro
# Reflection: async self-review after each send_message.
# When enabled, the agent's own model evaluates outgoing messages against
# criteria in the questions file. Dissonance triggers a πŸͺž reaction.
# See the dissonance skill for details.
reflection:
enabled: false
questions_file: state/is-dissonant-prompt.md
"""

DEFAULT_SCHEDULER = """\
Expand Down Expand Up @@ -174,6 +181,12 @@ def env_file(self) -> Path:
return self.home / ".env"


@dataclass
class ReflectionConfig:
enabled: bool = False
questions_file: str = "state/is-dissonant-prompt.md"


@dataclass
class AppConfig:
model: str = DEFAULT_MODEL
Expand All @@ -189,6 +202,7 @@ class AppConfig:
web_ui_channel_id: str = DEFAULT_WEB_UI_CHANNEL_ID
folders: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_FOLDERS))
mcp_servers: list[MCPServerConfig] = field(default_factory=list)
reflection: ReflectionConfig = field(default_factory=ReflectionConfig)
disable_builtin_skills: set[str] = field(default_factory=set)

@property
Expand Down Expand Up @@ -236,6 +250,15 @@ def _parse_folders(raw: Any) -> dict[str, str]:
return folders if folders else dict(DEFAULT_FOLDERS)


def _parse_reflection(raw: Any) -> ReflectionConfig:
if not isinstance(raw, dict):
return ReflectionConfig()
return ReflectionConfig(
enabled=bool(raw.get("enabled", False)),
questions_file=str(raw.get("questions_file", "state/is-dissonant-prompt.md")).strip(),
)


def load_config(layout: RepoLayout) -> AppConfig:
loaded = yaml.safe_load(layout.config_file.read_text(encoding="utf-8")) or {}
model_raw = loaded.get("model", DEFAULT_MODEL)
Expand All @@ -257,6 +280,7 @@ def load_config(layout: RepoLayout) -> AppConfig:
or DEFAULT_WEB_UI_CHANNEL_ID,
folders=_parse_folders(loaded.get("folders")),
mcp_servers=parse_mcp_server_configs(loaded.get("mcp_servers")),
reflection=_parse_reflection(loaded.get("reflection")),
disable_builtin_skills=_normalize_id_list(loaded.get("disable_builtin_skills")),
)

Expand Down Expand Up @@ -342,6 +366,17 @@ def bootstrap_home_repo(
_install_git_hook(layout.home)
_ensure_logs_ignored(layout.home)

# Write default dissonance questions file if reflection is configured.
reflection = _parse_reflection(loaded.get("reflection"))
questions_path = layout.home / reflection.questions_file
if not questions_path.exists():
questions_path.parent.mkdir(parents=True, exist_ok=True)
from .builtin_skills import BUILTIN_SKILLS

default_content = BUILTIN_SKILLS.get("dissonance/default-questions.md", "")
if default_content:
_write_if_missing(questions_path, default_content)


def _cleanup_legacy_builtin_scripts(layout: RepoLayout) -> None:
legacy_names = [
Expand Down
Loading