Compare commits

..

53 Commits

Author SHA1 Message Date
Karan c0271f73f6 feat: add WorldSim — OSINT-powered personality simulation skill
Rehoboam-class worldsim. Immersive CLI personality simulator that
researches real people via 25+ verified platform access methods,
builds 6-layer psychometric profiles, finds star threads (personality
compression keys), and generates platform-authentic simulated
conversations with mechanical verification and adversarial refinement.

26 files | 38K words | 2,283 lines Python

- Immersive CLI interface (worldsim> prompt, no assistant framing)
- OSINT pipeline: X API, Instagram private API, Bluesky, TikTok,
  Facebook, Threads, Mastodon, Reddit, GitHub, HN, Medium, Quora,
  Goodreads, Google Scholar, Crunchbase, podcasts, news/blogs
- Star thread: one-sentence personality compression key per person
- Deep psychometrics: Big Five + Moral Foundations + Schwartz Values
  + Cognitive Style + Narrative Framing + Behavioral Metadata
- Anti-slop: mechanical detection of LLM writing patterns
- GAN-style adversarial refinement loop with mechanical verification
- Recursive self-improvement: learned rules grow with each simulation
- Rehoboam persistence: SQLite + filesystem for profiles, predictions,
  social graph, knowledge archives
- GEPA/MIPROv2 self-evolution integration tested and working
- Knowledge archive: per-person source library with citations and
  semantic retrieval for context-aware grounding

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-04-08 13:46:20 -04:00
Jonathan Barket 7fe6782a25 feat(tools): add "no_mcp" sentinel to exclude MCP servers per platform
Currently, MCP servers are included on all platforms by default. If a
platform's toolset list does not explicitly name any MCP servers, every
globally enabled MCP server is injected. There is no way to opt a
platform out of MCP servers entirely.

This matters for the API server platform when used as an execution
backend — each spawned agent session gets the full MCP tool schema
injected into its system prompt, dramatically inflating token usage
(e.g. 57K tokens vs 9K without MCP tools) and slowing response times.

Add a "no_mcp" sentinel value for platform_toolsets. When present in a
platform's toolset list, all MCP servers are excluded for that platform.
Other platforms are unaffected.

Usage in config.yaml:

    platform_toolsets:
      api_server:
        - terminal
        - file
        - web
        - no_mcp    # exclude all MCP servers

The sentinel is filtered out of the final toolset — it does not appear
as an actual toolset name.
2026-04-07 18:00:01 -07:00
Teknium b9a5e6e247 fix: use camelCase structuredContent attr, prefer structured over text
- The MCP SDK Pydantic model uses camelCase (structuredContent), not
  snake_case (structured_content). The original getattr was a silent no-op.
- When structuredContent is present, return it AS the result instead of
  alongside text — the structured payload is the machine-readable data.
- Move test file to tests/tools/ and fix fake class to use camelCase.
- Patch _run_on_mcp_loop in tests so the handler actually executes.
2026-04-07 18:00:01 -07:00
r266-tech 363c5bc3c3 test(mcp): add structured_content preservation tests 2026-04-07 18:00:01 -07:00
r266-tech 2ad7694874 fix(mcp): preserve structured_content in tool call results
MCP CallToolResult may include structured_content (a JSON object) alongside
content blocks. The tool handler previously only forwarded concatenated text
from content blocks, silently dropping the structured payload.

This breaks MCP tools that return a minimal human text in content while
putting the actual machine-usable payload in structured_content.

Now, when structured_content is present, it is included in the returned
JSON under the 'structuredContent' key.

Fixes NousResearch/hermes-agent#5874
2026-04-07 18:00:01 -07:00
Teknium cbf1f15cfe fix(auxiliary): resolve named custom providers and 'main' alias in auxiliary routing (#5978)
* fix(telegram): replace substring caption check with exact line-by-line match

Captions in photo bursts and media group albums were silently dropped when
a shorter caption happened to be a substring of an existing one (e.g.
"Meeting" lost inside "Meeting agenda"). Extract a shared _merge_caption
static helper that splits on "\n\n" and uses exact match with whitespace
normalisation, then use it in both _enqueue_photo_event and
_queue_media_group_event.

Adds 13 unit tests covering the fixed bug scenarios.

Cherry-picked from PR #2671 by Dilee.

* fix: extend caption substring fix to all platforms

Move _merge_caption helper from TelegramAdapter to BasePlatformAdapter
so all adapters inherit it. Fix the same substring-containment bug in:
- gateway/platforms/base.py (photo burst merging)
- gateway/run.py (priority photo follow-up merging)
- gateway/platforms/feishu.py (media batch merging)

The original fix only covered telegram.py. The same bug existed in base.py
and run.py (pure substring check) and feishu.py (list membership without
whitespace normalization).

* fix(auxiliary): resolve named custom providers and 'main' alias in auxiliary routing

Two bugs caused auxiliary tasks (vision, compression, etc.) to fail when
using named custom providers defined in config.yaml:

1. 'provider: main' was hardcoded to 'custom', which only checks legacy
   OPENAI_BASE_URL env vars. Now reads _read_main_provider() to resolve
   to the actual provider (e.g., 'custom:beans', 'openrouter', 'deepseek').

2. Named custom provider names (e.g., 'beans') fell through to
   PROVIDER_REGISTRY which doesn't know about config.yaml entries.
   Now checks _get_named_custom_provider() before the registry fallback.

Fixes both resolve_provider_client() and _normalize_vision_provider()
so the fix covers all auxiliary tasks (vision, compression, web_extract,
session_search, etc.).

Adds 13 unit tests. Reported by Laura via Discord.

---------

Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>
2026-04-07 17:59:47 -07:00
Teknium 9692b3c28a fix: CLI/UX batch — ChatConsole errors, curses scroll, skin-aware banner, git state banner (#5974)
* fix(cli): route error messages through ChatConsole inside patch_stdout

Cherry-pick of PR #5798 by @icn5381.

Replace self.console.print() with ChatConsole().print() for 11 error/status
messages reachable during the interactive session. Inside patch_stdout,
self.console (plain Rich Console) writes raw ANSI escapes that StdoutProxy
mangles into garbled text. ChatConsole uses prompt_toolkit's native
print_formatted_text which renders correctly.

Same class of bug as #2262 — that fix covered agent output but missed
these error paths in _ensure_runtime_credentials, _init_agent, quick
commands, skill loading, and plan mode.

* fix(model-picker): add scrolling viewport to curses provider menu

Cherry-pick of PR #5790 by @Lempkey. Fixes #5755.

_curses_prompt_choice rendered items starting unconditionally from index 0
with no scroll offset. The 'More providers' submenu has 13 entries. On
terminals shorter than ~16 rows, items past the fold were never drawn.
When UP-arrow wrapped cursor from 0 to the last item (Cancel, index 12),
the highlight rendered off-screen — appearing as if only Cancel existed.

Adds scroll_offset tracking that adjusts each frame to keep the cursor
inside the visible window.

* feat(cli): skin-aware compact banner + git state in startup banner

Combined salvage of PR #5922 by @ASRagab and PR #5877 by @xinbenlv.

Compact banner changes (from #5922):
- Read active skin colors and branding instead of hardcoding gold/NOUS HERMES
- Default skin preserves backward-compatible legacy branding
- Non-default skins use their own agent_name and colors

Git state in banner (from #5877):
- New format_banner_version_label() shows upstream/local git hashes
- Full banner title now includes git state (upstream hash, carried commits)
- Compact banner line2 shows the version label with git state
- Widen compact banner max width from 64 to 88 to fit version info

Both the full Rich banner and compact fallback are now skin-aware
and show git state.
2026-04-07 17:59:42 -07:00
Teknium f3c59321af fix: add _profile_arg tests + move STT language to config.yaml
- Add 7 unit tests for _profile_arg: default home, named profile,
  hash path, nested path, invalid name, systemd integration, launchd integration
- Add stt.local.language to config.yaml (empty = auto-detect)
- Both STT code paths now read config.yaml first, env var fallback,
  then default (auto-detect for faster-whisper, 'en' for CLI command)
- HERMES_LOCAL_STT_LANGUAGE env var still works as backward-compat fallback
2026-04-07 17:59:16 -07:00
Marc Bickel 6e02fa73c2 fix(discord): discard empty placeholder on voice transcription + force STT language
- gateway/run.py: Strip "(The user sent a message with no text content)"
  placeholder when voice transcription succeeds — it was being appended
  alongside the transcript, creating duplicate user turns.
- tools/transcription_tools.py: Wire HERMES_LOCAL_STT_LANGUAGE env var
  into the faster-whisper backend. It was only used by the CLI fallback
  path (_transcribe_local_command), not the primary faster-whisper path.
2026-04-07 17:59:16 -07:00
Marc Bickel 25080986a0 fix(gateway): discard empty placeholder when voice transcription succeeds
When a Discord voice message arrives, the adapter sets event.text to
"(The user sent a message with no text content)" since voice messages
have no text content. The transcription enrichment in
_enrich_message_with_transcription() then prepends the transcript but
leaves the placeholder intact, causing the agent to receive both:

    [The user sent a voice message~ Here's what they said: "..."]

    (The user sent a message with no text content)

The agent sees this as two separate user turns — one transcribed
and one empty — creating confusing duplicate messages.

Fix: when the transcription succeeds and user_text is only the empty
placeholder, return just the transcript without the redundant placeholder.
2026-04-07 17:59:16 -07:00
Jarvis AI c3158d38b2 fix(gateway): include --profile in launchd/systemd argv for named profiles
generate_launchd_plist() and generate_systemd_unit() were missing the
--profile <name> argument in ProgramArguments/ExecStart, causing
hermes gateway start to regenerate plists that fell back to
~/.hermes/active_profile instead of the intended profile.

Fix:
- Add _profile_arg(hermes_home?) helper returning '--profile <name>'
  only for ~/.hermes/profiles/<name> paths, empty string otherwise.
- Update generate_launchd_plist() to build ProgramArguments array
  dynamically with --profile when applicable.
- Update generate_systemd_unit() both user and system service
  branches with {profile_arg} in ExecStart.

This ensures hermes --profile <name> gateway start produces a
service definition that correctly scopes to the named profile.
2026-04-07 17:59:16 -07:00
Teknium 50d1518df6 fix(tests): update tool_progress_callback test calls to new 4-arg signature
Follow-up to sroecker's PR #5918 — test mocks were using the old 3-arg
callback signature (name, preview, args) instead of the new
(event_type, name, preview, args, **kwargs).
2026-04-07 17:56:01 -07:00
pradeep7127 1d5a69a445 fix(api_server): preserve conversation history when /v1/runs input is a message array
When /v1/runs receives an OpenAI-style array of messages as input, all
messages except the last user turn are now extracted as conversation_history.
Previously only the last message was kept, silently discarding earlier
context in multi-turn conversations.

Handles multi-part content blocks by flattening text portions. Only fires
when no explicit conversation_history was provided.

Based on PR #5837 by pradeep7127.
2026-04-07 17:56:01 -07:00
VanBladee 786038443e feat(api): accept conversation_history in request body
Allow clients to pass explicit conversation_history in /v1/responses and
/v1/runs request bodies instead of relying on server-side response chaining
via previous_response_id. Solves problems with stateless deployments where
the in-memory ResponseStore is lost on restart.

Adds input validation (must be array of {role, content} objects) and clear
precedence: explicit conversation_history > previous_response_id.

Based on PR #5805 by VanBladee, with added input validation.
2026-04-07 17:56:01 -07:00
Steffen Röcker 7ec838507a fix(api_server): update tool_progress_callback signature for Open WebUI streaming
Commit cc2b56b2 changed the tool_progress_callback signature from
(name, preview, args) to (event_type, name, preview, args, **kwargs)
but the API server's chat completion streaming callback was not updated.

This caused tool calls to not display in Open WebUI because the
callback received arguments in wrong positions.

- Update _on_tool_progress to use new 4-arg signature
- Add event_type filter to only show tool.started events
- Add **kwargs for optional duration/is_error parameters
2026-04-07 17:56:01 -07:00
Teknium efbe8d674a docs: add Discord channel controls and Telegram reactions documentation
- Discord: ignored_channels, no_thread_channels config reference + examples
- Telegram: message reactions section with config, behavior notes
- Environment variables reference updated for all new vars
2026-04-07 17:55:55 -07:00
Teknium a6547f399f test: add tests for Discord channel controls and Telegram reactions
- 14 tests for ignored_channels, no_thread_channels, and config bridging
- 17 tests for reaction enable/disable, API calls, error handling, and config
2026-04-07 17:55:55 -07:00
Teknium 52b3a3ca3a fix: default Telegram reactions to off, remove dead _remove_reaction
Telegram's set_message_reaction replaces all reactions in one call,
so _remove_reaction was never called (unlike Discord's additive model).
Default reactions to disabled — users opt in via telegram.reactions: true.
2026-04-07 17:55:55 -07:00
Alvaro Linares 74b0072f8f feat(telegram): add message reactions on processing start/complete
Mirror the Discord reaction pattern for Telegram:
- 👀 (eyes) when message processing begins
-  (check) on successful completion
-  (cross) on failure

Controlled via TELEGRAM_REACTIONS env var or telegram.reactions
in config.yaml (enabled by default, like Discord).

Uses python-telegram-bot's Bot.set_message_reaction() API.
Failures are caught and logged at debug level so they never
break message processing.
2026-04-07 17:55:55 -07:00
Angello Picasso f6d4b6a319 feat(discord): add ignored_channels and no_thread_channels config
- ignored_channels: channels where bot never responds (even when mentioned)
- no_thread_channels: channels where bot responds directly without thread

Both support config.yaml and env vars (DISCORD_IGNORED_CHANNELS,
DISCORD_NO_THREAD_CHANNELS), following existing pattern for
free_response_channels.

Fixes #5881
2026-04-07 17:55:55 -07:00
lesterli 37bf19a29d fix(codex): align validation with normalization for empty stream output
The response validation stage unconditionally marked Codex Responses API
replies as invalid when response.output was empty, triggering unnecessary
retries and fallback chains. However, _normalize_codex_response can
recover from this state by synthesizing output from response.output_text.

Now the validation stage checks for output_text before marking the
response invalid, matching the normalization logic. Also fixes
logging.warning → logger.warning for consistency with the rest of the
file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:29:41 -07:00
Teknium 469cd16fe0 fix(security): consolidated security hardening — SSRF, timing attack, tar traversal, credential leakage (#5944)
Salvaged from PRs #5800 (memosr), #5806 (memosr), #5915 (Ruzzgar), #5928 (Awsh1).

Changes:
- Use hmac.compare_digest for API key comparison (timing attack prevention)
- Apply provider env var blocklist to Docker containers (credential leakage)
- Replace tar.extractall() with safe extraction in TerminalBench2 (CVE-2007-4559)
- Add SSRF protection via is_safe_url to ALL platform adapters:
  base.py (cache_image_from_url, cache_audio_from_url),
  discord, slack, telegram, matrix, mattermost, feishu, wecom
  (Signal and WhatsApp protected via base.py helpers)
- Update tests: mock is_safe_url in Mattermost download tests
- Add security tests for tar extraction (traversal, symlinks, safe files)
2026-04-07 17:28:37 -07:00
Teknium b1a66d55b4 refactor: migrate 10 config.yaml inline loaders to read_raw_config()
Replace 10 callsites across 6 files that manually opened config.yaml,
called yaml.safe_load(), and handled missing-file/parse-error fallbacks
with the new read_raw_config() helper from hermes_cli/config.py.

Each migrated site previously had 5-8 lines of boilerplate:
    config_path = get_hermes_home() / 'config.yaml'
    if config_path.exists():
        import yaml
        with open(config_path) as f:
            cfg = yaml.safe_load(f) or {}

Now reduced to:
    from hermes_cli.config import read_raw_config
    cfg = read_raw_config()

Migrated files:
- tools/browser_tool.py (4 sites): command_timeout, cloud_provider,
  allow_private_urls, record_sessions
- tools/env_passthrough.py: terminal.env_passthrough
- tools/credential_files.py: terminal.credential_files
- tools/transcription_tools.py: stt.model
- hermes_cli/commands.py: config-gated command resolution
- hermes_cli/auth.py (2 sites): model config read + provider reset

Skipped (intentionally):
- gateway/run.py: 10+ sites with local aliases, critical path
- hermes_cli/profiles.py: profile-specific config path
- hermes_cli/doctor.py: reads raw then writes fixes back
- agent/model_metadata.py: different file (context_length_cache.yaml)
- tools/website_policy.py: custom config_path param + error types
2026-04-07 17:28:23 -07:00
Zainan Victor Zhou 0d41fb0827 fix(gateway): show full session id and title in /status 2026-04-07 17:27:09 -07:00
Jeff Escalante 4aef055805 fix(gateway/webhook): don't pop delivery_info on send
The webhook adapter stored per-request `deliver`/`deliver_extra` config in
`_delivery_info[chat_id]` during POST handling and consumed it via `.pop()`
inside `send()`. That worked for routes whose agent run produced exactly
one outbound message — the final response — but it broke whenever the
agent emitted any interim status message before the final response.

Status messages flow through the same `send(chat_id, ...)` path as the
final response (see `gateway/run.py::_status_callback_sync` →
`adapter.send(...)`). Common triggers include:

  - "🔄 Primary model failed — switching to fallback: ..."
    (run_agent.py::_emit_status when `fallback_providers` activates)
  - context-pressure / compression notices
  - any other lifecycle event routed through `status_callback`

When any of those fired, the first `send()` call popped the entry, so the
subsequent final-response `send()` saw an empty dict and silently
downgraded `deliver_type` from `"telegram"` (or `discord`/`slack`/etc.) to
the default `"log"`. The agent's response was logged to the gateway log
instead of being delivered to the configured cross-platform target — no
warning, no error, just a missing message.

This was easy to hit in practice. Any user with `fallback_providers`
configured saw it the first time their primary provider hiccuped on a
webhook-triggered run. Routes that worked perfectly in dev (where the
primary stays healthy) silently dropped responses in prod.

Fix: read `_delivery_info` with `.get()` so multiple `send()` calls for
the same `chat_id` all see the same delivery config. To keep the dict
bounded without relying on per-send cleanup, add a parallel
`_delivery_info_created` timestamp dict and a `_prune_delivery_info()`
helper that drops entries older than `_idempotency_ttl` (1h, same window
already used by `_seen_deliveries`). Pruning runs on each POST, mirroring
the existing `_seen_deliveries` cleanup pattern.

Worst-case memory footprint is now `rate_limit * TTL = 30/min * 60min =
1800` entries, each ~1KB → under 2 MB. In practice it'll be far smaller
because most webhooks complete in seconds, not the full hour.

Test changes:
  - `test_delivery_info_cleaned_after_send` is replaced with
    `test_delivery_info_survives_multiple_sends`, which is now the
    regression test for this bug — it asserts that two consecutive
    `send()` calls both see the delivery config.
  - A new `test_delivery_info_pruned_via_ttl` covers the TTL cleanup
    behavior.
  - The two integration tests that asserted `chat_id not in
    adapter._delivery_info` after `send()` now assert the opposite, with
    a comment explaining why.

All 40 tests in `tests/gateway/test_webhook_adapter.py` and
`tests/gateway/test_webhook_integration.py` pass. Verified end-to-end
locally against a dynamic `hermes webhook subscribe` route configured
with `--deliver telegram --deliver-chat-id <user>`: with `gpt-5.4` as
the primary (currently flaky) and `claude-opus-4.6` as the fallback,
the fallback notification fires, the agent finishes, and the final
response is delivered to Telegram as expected.
2026-04-07 17:27:09 -07:00
Siddharth Balyan f3006ebef9 refactor(tests): re-architect tests + fix CI failures (#5946)
* refactor: re-architect tests to mirror the codebase

* Update tests.yml

* fix: add missing tool_error imports after registry refactor

* fix(tests): replace patch.dict with monkeypatch to prevent env var leaks under xdist

patch.dict(os.environ) can leak TERMINAL_ENV across xdist workers,
causing test_code_execution tests to hit the Modal remote path.

* fix(tests): fix update_check and telegram xdist failures

- test_update_check: replace patch("hermes_cli.banner.os.getenv") with
  monkeypatch.setenv("HERMES_HOME") — banner.py no longer imports os
  directly, it uses get_hermes_home() from hermes_constants.

- test_telegram_conflict/approval_buttons: provide real exception classes
  for telegram.error mock (NetworkError, TimedOut, BadRequest) so the
  except clause in connect() doesn't fail with "catching classes that do
  not inherit from BaseException" when xdist pollutes sys.modules.

* fix(tests): accept unavailable_models kwarg in _prompt_model_selection mock
2026-04-07 17:19:07 -07:00
Teknium 99ff375f7a fix(gateway): respect tool_preview_length in all/new progress modes (#5937)
Previously, all/new tool progress modes always hard-truncated previews
to 40 chars, ignoring the display.tool_preview_length config. This made
it impossible for gateway users to see meaningful command/path info
without switching to verbose mode (which shows too much detail).

Now all/new modes read tool_preview_length from config:
- tool_preview_length: 0 (default/unset) → 40 chars (no regression)
- tool_preview_length: 120 → 120-char previews in all/new mode
- verbose mode: unchanged (already respected the config)

Users who want longer previews can set:
  display:
    tool_preview_length: 120

Reported by demontut_ on Discord.
2026-04-07 14:10:56 -07:00
Teknium 125e5ef089 fix: extend caption substring fix to all platforms
Move _merge_caption helper from TelegramAdapter to BasePlatformAdapter
so all adapters inherit it. Fix the same substring-containment bug in:
- gateway/platforms/base.py (photo burst merging)
- gateway/run.py (priority photo follow-up merging)
- gateway/platforms/feishu.py (media batch merging)

The original fix only covered telegram.py. The same bug existed in base.py
and run.py (pure substring check) and feishu.py (list membership without
whitespace normalization).
2026-04-07 14:08:59 -07:00
Dilee 4a630c2071 fix(telegram): replace substring caption check with exact line-by-line match
Captions in photo bursts and media group albums were silently dropped when
a shorter caption happened to be a substring of an existing one (e.g.
"Meeting" lost inside "Meeting agenda"). Extract a shared _merge_caption
static helper that splits on "\n\n" and uses exact match with whitespace
normalisation, then use it in both _enqueue_photo_event and
_queue_media_group_event.

Adds 13 unit tests covering the fixed bug scenarios.

Cherry-picked from PR #2671 by Dilee.
2026-04-07 14:08:59 -07:00
Teknium 7b18eeee9b feat(supermemory): add multi-container, search_mode, identity template, and env var override (#5933)
Based on PR #5413 spec by MaheshtheDev (Mahesh Sanikommu).

Changes:
- Add search_mode config (hybrid/memories/documents) passed to SDK
- Add {identity} template support in container_tag for profile-scoped containers
- Add SUPERMEMORY_CONTAINER_TAG env var override (priority over config)
- Add multi-container mode: enable_custom_container_tags, custom_containers,
  custom_container_instructions in supermemory.json
- Dynamic tool schemas when multi-container enabled (optional container_tag param)
- Whitelist validation for custom container tags in tool calls
- Simplify get_config_schema() to only prompt for API key during setup
- Defer container_tag sanitization to initialize() (after template resolution)
- Add custom_id support to documents.add calls
- Update README with multi-container docs, search_mode, identity template,
  support links (Discord, email)
- Update memory-providers.md with new features and multi-container example
- Update memory-provider-plugin.md with minimal vs full schema guidance
- Add 12 new tests covering identity template, search_mode, multi-container,
  config schema, and env var override
2026-04-07 14:03:46 -07:00
Teknium 678a87c477 refactor: add tool_error/tool_result helpers + read_raw_config, migrate 129 callsites
Add three reusable helpers to eliminate pervasive boilerplate:

tools/registry.py — tool_error() and tool_result():
  Every tool handler returns JSON strings. The pattern
  json.dumps({"error": msg}, ensure_ascii=False) appeared 106 times,
  and json.dumps({"success": False, "error": msg}, ...) another 23.
  Now: tool_error(msg) or tool_error(msg, success=False).

  tool_result() handles arbitrary result dicts:
  tool_result(success=True, data=payload) or tool_result(some_dict).

hermes_cli/config.py — read_raw_config():
  Lightweight YAML reader that returns the raw config dict without
  load_config()'s deep-merge + migration overhead. Available for
  callsites that just need a single config value.

Migration (129 callsites across 32 files):
- tools/: browser_camofox (18), file_tools (10), homeassistant (8),
  web_tools (7), skill_manager (7), cronjob (11), code_execution (4),
  delegate (5), send_message (4), tts (4), memory (7), session_search (3),
  mcp (2), clarify (2), skills_tool (3), todo (1), vision (1),
  browser (1), process_registry (2), image_gen (1)
- plugins/memory/: honcho (9), supermemory (9), hindsight (8),
  holographic (7), openviking (7), mem0 (7), byterover (6), retaindb (2)
- agent/: memory_manager (2), builtin_memory_provider (1)
2026-04-07 13:36:38 -07:00
Teknium ab8f9c089e feat: thinking-only prefill continuation for structured reasoning responses (#5931)
When the model produces structured reasoning (via API fields like .reasoning,
.reasoning_content, .reasoning_details) but no visible text content, append
the assistant message as prefill and continue the loop. The model sees its own
reasoning context on the next turn and produces the text portion.

Inspired by clawdbot's 'incomplete-text' recovery pattern. Up to 2 prefill
attempts before falling through to the existing '(empty)' terminal.

Key design decisions:
- Only triggers for structured reasoning (API fields), NOT inline <think> tags
- Prefill messages are popped on success to maintain strict role alternation
- _thinking_prefill marker stripped from all API message building paths
- Works across all providers: OpenAI (continuation), Anthropic (native prefill)

Verified with E2E tests: simulated thinking-only → real OpenRouter continuation
produces correct content. Also confirmed Qwen models consistently produce
structured-reasoning-only responses under token pressure.
2026-04-07 13:19:06 -07:00
Teknium 6e2f6a25a1 refactor: deduplicate PowerShell script constants between Windows and WSL paths
Move _PS_CHECK_IMAGE and _PS_EXTRACT_IMAGE above both the native Windows
and WSL2 sections so both can share them. Removes the duplicate
_WIN_PS_CHECK / _WIN_PS_EXTRACT constants.
2026-04-07 12:49:39 -07:00
kshitijk4poor f4528c885b feat(clipboard): add native Windows image paste support
Add win32 platform branch to clipboard.py so Ctrl+V image paste
works on native Windows (PowerShell / Windows Terminal), not just
WSL2.

Uses the same .NET System.Windows.Forms.Clipboard approach as the
WSL path but calls PowerShell directly instead of powershell.exe
(the WSL cross-call path).  Tries 'powershell' first (Windows
PowerShell 5.1, always available), then 'pwsh' (PowerShell 7+).

PowerShell executable is discovered once and cached for the process
lifetime.

Includes 14 new tests covering:
- Platform dispatch (save_clipboard_image + has_clipboard_image)
- Image detection via PowerShell .NET check
- Base64 PNG extraction and decode
- Edge cases: no PowerShell, empty output, invalid base64, timeout
2026-04-07 12:49:39 -07:00
Teknium c040b0e4ae test: add unit tests for media helper — video, document, multi-file, failure isolation
Adapted from PR #5679 (0xbyt4) to cover edge cases not in the integration tests:
video routing, unknown extension fallback to send_document, multi-file delivery,
and single-failure isolation.
2026-04-07 12:49:25 -07:00
kshitijk4poor 0f3895ba29 fix(cron): deliver MEDIA files as native platform attachments
The cron delivery path sent raw 'MEDIA:/path/to/file' text instead
of uploading the file as a native attachment.  The standalone path
(via _send_to_platform) already extracted MEDIA tags and forwarded
them as media_files, but the live adapter path passed the unprocessed
delivery_content directly to adapter.send().

Two bugs fixed:
1. Live adapter path now sends cleaned text (MEDIA tags stripped)
   instead of raw content — prevents 'MEDIA:/path' from appearing
   as literal text in Discord/Telegram/etc.
2. Live adapter path now sends each extracted media file via the
   adapter's native method (send_voice for audio, send_image_file
   for images, send_video for video, send_document as fallback) —
   files are uploaded as proper platform attachments.

The file-type routing mirrors BasePlatformAdapter._process_message_background
to ensure consistent behavior between normal gateway responses and
cron-delivered responses.

Adds 2 tests:
- test_live_adapter_sends_media_as_attachments: verifies Discord
  adapter receives send_voice call for .mp3 file
- test_live_adapter_sends_cleaned_text_not_raw: verifies MEDIA tag
  stripped from text sent via live adapter
2026-04-07 12:49:25 -07:00
Teknium ca0459d109 refactor: remove 24 confirmed dead functions — 432 lines of unused code
Each function was verified to have exactly 1 reference in the entire
codebase (its own definition). Zero calls, zero imports, zero string
references anywhere including tests.

Removed by category:

Superseded wrappers (replaced by newer implementations):
- agent/anthropic_adapter.py: run_hermes_oauth_login, refresh_hermes_oauth_token
- hermes_cli/callbacks.py: sudo_password_callback (superseded by CLI method)
- hermes_cli/setup.py: _set_model_provider, _sync_model_from_disk
- tools/file_tools.py: get_file_tools (superseded by registry.register)
- tools/cronjob_tools.py: get_cronjob_tool_definitions (same)
- tools/terminal_tool.py: _check_dangerous_command (_check_all_guards used)

Dead private helpers (lost their callers during refactors):
- agent/anthropic_adapter.py: _convert_user_content_part_to_anthropic
- agent/display.py: honcho_session_line, write_tty
- hermes_cli/providers.py: _build_labels (+ dead _labels_cache var)
- hermes_cli/tools_config.py: _prompt_yes_no
- hermes_cli/models.py: _extract_model_ids
- hermes_cli/uninstall.py: log_error
- gateway/platforms/feishu.py: _is_loop_ready
- tools/file_operations.py: _read_image (64-line method)
- tools/process_registry.py: cleanup_expired
- tools/skill_manager_tool.py: check_skill_manage_requirements

Dead class methods (zero callers):
- run_agent.py: _is_anthropic_url (logic duplicated inline at L618)
- run_agent.py: _classify_empty_content_response (68-line method, never wired)
- cli.py: reset_conversation (callers all use new_session directly)
- cli.py: _clear_current_input (added but never wired in)

Other:
- gateway/delivery.py: build_delivery_context_for_tool
- tools/browser_tool.py: get_active_browser_sessions
2026-04-07 11:41:26 -07:00
Teknium 69c753c19b fix: thread gateway user_id to memory plugins for per-user scoping (#5895)
Memory plugins (Mem0, Honcho) used static identifiers ('hermes-user',
config peerName) meaning all gateway users shared the same memory bucket.

Changes:
- AIAgent.__init__: add user_id parameter, store as self._user_id
- run_agent.py: include user_id in _init_kwargs passed to memory providers
- gateway/run.py: pass source.user_id to AIAgent in primary + background paths
- Mem0 plugin: prefer kwargs user_id over config default
- Honcho plugin: override cfg.peer_name with gateway user_id when present

CLI sessions (user_id=None) preserve existing defaults. Only gateway
sessions with a real platform user_id get per-user memory scoping.

Reported by plev333.
2026-04-07 11:14:12 -07:00
Teknium e49c8bbbbb feat(slack): thread engagement — auto-respond in bot-started and mentioned threads (#5897)
When the bot sends a message in a thread, track its ts in _bot_message_ts.
When the bot is @mentioned in a thread, register it in _mentioned_threads.
Both sets enable auto-responding to future messages in those threads
without requiring repeated @mentions — making the bot behave like a
team member that stays engaged once a conversation starts.

Channel message gating now checks 4 signals (in order):
  1. @mention in this message
  2. Reply in a thread the bot started/participated in (_bot_message_ts)
  3. Message in a thread where the bot was previously @mentioned (_mentioned_threads)
  4. Existing session for this thread (_has_active_session_for_thread — survives restarts)

Thread context fetching now triggers on ANY first-entry path (not just
@mention), so the agent gets context whether it's entering via a mention,
a bot-thread reply, or a mentioned-thread auto-trigger.

Both tracking sets are bounded (5000 cap with prune-oldest-half) to prevent
unbounded memory growth in long-running deployments.

Salvaged from PR #5754 by @hhhonzik. Preserves our existing approval buttons,
thread context fetching, and session key fix. Does NOT include the
edit_message format_message() removal (that was a regression in the original PR).

Tests: 4 new tests for bot-ts tracking and mentioned-thread bounds.
2026-04-07 11:12:08 -07:00
Teknium ab0c1e58f1 fix: pause typing indicator during approval waits (#5893)
When the agent waits for dangerous-command approval, the typing
indicator (_keep_typing loop) kept refreshing. On Slack's Assistant
API this is critical: assistant_threads_setStatus disables the
compose box, preventing users from typing /approve or /deny.

- Add _typing_paused set + pause/resume methods to BasePlatformAdapter
- _keep_typing skips send_typing when chat_id is paused
- _approval_notify_sync pauses typing before sending approval prompt
- _handle_approve_command / _handle_deny_command resume typing after

Benefits all platforms — no reason to show 'is thinking...' while
the agent is idle waiting for human input.
2026-04-07 11:04:50 -07:00
Teknium 1a2a03ca69 feat(gateway): approval buttons for Slack & Telegram + Slack thread context (#5890)
Slack:
- Add Block Kit interactive buttons for command approval (Allow Once,
  Allow Session, Always Allow, Deny) via send_exec_approval()
- Register @app.action handlers for each approval button
- Add _fetch_thread_context() — fetches thread history via
  conversations.replies when bot is first @mentioned mid-thread
- Fix _has_active_session_for_thread() to use build_session_key()
  instead of manual key construction (fixes session key mismatch bug
  where thread_sessions_per_user flag was ignored, ref PR #5833)

Telegram:
- Add InlineKeyboard approval buttons via send_exec_approval()
- Add ea:* callback handling in _handle_callback_query()
- Uses monotonic counter + _approval_state dict to map button clicks
  back to session keys (avoids 64-byte callback_data limit)

Both platforms now auto-detected by the gateway runner's
_approval_notify_sync() — any adapter with send_exec_approval() on
its class gets button-based approval instead of text fallback.

Inspired by community PRs #3898 (LevSky22), #2953 (ygd58), #5833
(heathley). Implemented fresh on current main.

Tests: 24 new tests covering button rendering, action handling,
thread context fetching, session key fix, double-click prevention.
2026-04-07 11:03:14 -07:00
Teknium 187e90e425 refactor: replace inline HERMES_HOME re-implementations with get_hermes_home()
16 callsites across 14 files were re-deriving the hermes home path
via os.environ.get('HERMES_HOME', ...) instead of using the canonical
get_hermes_home() from hermes_constants. This breaks profiles — each
profile has its own HERMES_HOME, and the inline fallback defaults to
~/.hermes regardless.

Fixed by importing and calling get_hermes_home() at each site. For
files already inside the hermes process (agent/, hermes_cli/, tools/,
gateway/, plugins/), this is always safe. Files that run outside the
process context (mcp_serve.py, mcp_oauth.py) already had correct
try/except ImportError fallbacks and were left alone.

Skipped: hermes_constants.py (IS the implementation), env_loader.py
(bootstrap), profiles.py (intentionally manipulates the env var),
standalone scripts (optional-skills/, skills/), and tests.
2026-04-07 10:40:34 -07:00
Teknium d0ffb111c2 refactor: codebase-wide lint cleanup — unused imports, dead code, and inefficient patterns (#5821)
Comprehensive cleanup across 80 files based on automated (ruff, pyflakes, vulture)
and manual analysis of the entire codebase.

Changes by category:

Unused imports removed (~95 across 55 files):
- Removed genuinely unused imports from all major subsystems
- agent/, hermes_cli/, tools/, gateway/, plugins/, cron/
- Includes imports in try/except blocks that were truly unused
  (vs availability checks which were left alone)

Unused variables removed (~25):
- Removed dead variables: connected, inner, channels, last_exc,
  source, new_server_names, verify, pconfig, default_terminal,
  result, pending_handled, temperature, loop
- Dropped unused argparse subparser assignments in hermes_cli/main.py
  (12 instances of add_parser() where result was never used)

Dead code removed:
- run_agent.py: Removed dead ternary (None if False else None) and
  surrounding unreachable branch in identity fallback
- run_agent.py: Removed write-only attribute _last_reported_tool
- hermes_cli/providers.py: Removed dead @property decorator on
  module-level function (decorator has no effect outside a class)
- gateway/run.py: Removed unused MCP config load before reconnect
- gateway/platforms/slack.py: Removed dead SessionSource construction

Undefined name bugs fixed (would cause NameError at runtime):
- batch_runner.py: Added missing logger = logging.getLogger(__name__)
- tools/environments/daytona.py: Added missing Dict and Path imports

Unnecessary global statements removed (14):
- tools/terminal_tool.py: 5 functions declared global for dicts
  they only mutated via .pop()/[key]=value (no rebinding)
- tools/browser_tool.py: cleanup thread loop only reads flag
- tools/rl_training_tool.py: 4 functions only do dict mutations
- tools/mcp_oauth.py: only reads the global
- hermes_time.py: only reads cached values

Inefficient patterns fixed:
- startswith/endswith tuple form: 15 instances of
  x.startswith('a') or x.startswith('b') consolidated to
  x.startswith(('a', 'b'))
- len(x)==0 / len(x)>0: 13 instances replaced with pythonic
  truthiness checks (not x / bool(x))
- in dict.keys(): 5 instances simplified to in dict
- Redefined unused name: removed duplicate _strip_mdv2 import in
  send_message_tool.py

Other fixes:
- hermes_cli/doctor.py: Replaced undefined logger.debug() with pass
- hermes_cli/config.py: Consolidated chained .endswith() calls

Test results: 3934 passed, 17 failed (all pre-existing on main),
19 skipped. Zero regressions.
2026-04-07 10:25:31 -07:00
Teknium afe6c63c52 docs: comprehensive docs audit — cover 13 features from last week's PRs (#5815)
Cover documentation gaps found by auditing all 50+ merged PRs from the past week:

tools-reference.md:
- Fix stale tool count (47→46, 11→10 browser tools) after browser_close removal
- Document notify_on_complete parameter in terminal tool description

telegram.md:
- Add Interactive Model Picker section (inline keyboard, provider/model drill-down)

discord.md:
- Add Interactive Model Picker section (Select dropdowns, 120s timeout)
- Add Native Slash Commands for Skills section (auto-registration at startup)

signal.md:
- Expand Attachments section with outgoing media delivery (send_image_file,
  send_voice, send_video, send_document via MEDIA: tags)

webhooks.md:
- Document {__raw__} special template token for full payload access
- Document Forum Topic Delivery via message_thread_id in deliver_extra

slack.md:
- Fix stale/misleading thread reply docs — thread replies no longer require
  @mention when bot has active session (3 locations updated)

security.md:
- Add cross-session isolation (layer 6) and input sanitization (layer 7)
  to security layers overview

feishu.md:
- Add WebSocket Tuning section (ws_reconnect_interval, ws_ping_interval)
- Add Per-Group Access Control section (group_rules with 5 policy types)

credential-pools.md:
- Add Delegation & Subagent Sharing section

delegation.md:
- Update key properties to mention credential pool inheritance

providers.md:
- Add Z.AI Endpoint Auto-Detection note
- Add xAI (Grok) Prompt Caching section

skills-catalog.md:
- Add p5js to creative skills category
2026-04-07 10:21:03 -07:00
Teknium c58e16757a docs: fix 40+ discrepancies between documentation and codebase (#5818)
Comprehensive audit of all ~100 doc pages against the actual code, fixing:

Reference docs:
- HERMES_API_TIMEOUT default 900 -> 1800 (env-vars)
- TERMINAL_DOCKER_IMAGE default python:3.11 -> nikolaik/python-nodejs (env-vars)
- compression.summary_model default shown as gemini -> actually empty string (env-vars)
- Add missing GOOGLE_API_KEY, GEMINI_API_KEY, GEMINI_BASE_URL env vars (env-vars)
- Add missing /branch (/fork) slash command (slash-commands)
- Fix hermes-cli tool count 39 -> 38 (toolsets-reference)
- Fix hermes-api-server drop list to include text_to_speech (toolsets-reference)
- Fix total tool count 47 -> 48, standalone 14 -> 15 (tools-reference)

User guide:
- web_extract.timeout default 30 -> 360 (configuration)
- Remove display.theme_mode (not implemented in code) (configuration)
- Remove display.background_process_notifications (not in defaults) (configuration)
- Browser inactivity timeout 300/5min -> 120/2min (browser)
- Screenshot path browser_screenshots -> cache/screenshots (browser)
- batch_runner default model claude-sonnet-4-20250514 -> claude-sonnet-4.6
- Add minimax to TTS provider list (voice-mode)
- Remove credential_pool_strategies from auth.json example (credential-pools)
- Fix Slack token path platforms/slack/ -> root ~/.hermes/ (slack)
- Fix Matrix store path for new installs (matrix)
- Fix WhatsApp session path for new installs (whatsapp)
- Fix HomeAssistant config from gateway.json to config.yaml (homeassistant)
- Fix WeCom gateway start command (wecom)

Developer guide:
- Fix tool/toolset counts in architecture overview
- Update line counts: main.py ~5500, setup.py ~3100, run.py ~7500, mcp_tool ~2200
- Replace nonexistent agent/memory_store.py with memory_manager.py + memory_provider.py
- Update _discover_tools() list: remove honcho_tools, add skill_manager_tool
- Add session_search and delegate_task to intercepted tools list (agent-loop)
- Fix budget warning: two-tier system (70% caution, 90% warning) (agent-loop)
- Fix gateway auth order (per-platform first, global last) (gateway-internals)
- Fix email_adapter.py -> email.py, add webhook.py + api_server.py (gateway-internals)
- Add 7 missing providers to provider-runtime list

Other:
- Add Docker --cap-add entries to security doc
- Fix Python version 3.10+ -> 3.11+ (contributing)
- Fix AGENTS.md discovery claim (not hierarchical walk) (tips)
- Fix cron 'add' -> canonical 'create' (cron-internals)
- Add pre_api_request/post_api_request hooks to plugin guide
- Add Google/Gemini provider to providers page
- Clarify OPENAI_BASE_URL deprecation (providers)
2026-04-07 10:17:44 -07:00
Teknium aa7473cabd feat: replace z-ai/glm-5 with z-ai/glm-5.1 in OpenRouter and Nous model lists 2026-04-07 10:16:24 -07:00
Teknium caded0a5e7 fix: repair 57 failing CI tests across 14 files (#5823)
* fix: repair 57 failing CI tests across 14 files

Categories of fixes:

**Test isolation under xdist (-n auto):**
- test_hermes_logging: Strip ALL RotatingFileHandlers before each test
  to prevent handlers leaked from other xdist workers from polluting counts
- test_code_execution: Force TERMINAL_ENV=local in setUp — prevents Modal
  AuthError when another test leaks TERMINAL_ENV=modal
- test_timezone: Same TERMINAL_ENV fix for execute_code timezone tests
- test_codex_execution_paths: Mock _resolve_turn_agent_config to ensure
  model resolution works regardless of xdist worker state

**Matrix adapter tests (nio not installed in CI):**
- Add _make_fake_nio() helper with real response classes for isinstance()
  checks in production code
- Replace MagicMock(spec=nio.XxxResponse) with fake_nio instances
- Wrap production method calls with patch.dict('sys.modules', {'nio': ...})
  so import nio succeeds in method bodies
- Use try/except instead of pytest.importorskip for nio.crypto imports
  (importorskip can be fooled by MagicMock in sys.modules)
- test_matrix_voice: Skip entire file if nio is a mock, not just missing

**Stale test expectations:**
- test_cli_provider_resolution: _prompt_provider_choice now takes **kwargs
  (default param added); mock getpass.getpass alongside input
- test_anthropic_oauth_flow: Mock getpass.getpass (code switched from input)
- test_gemini_provider: Mock models.dev + OpenRouter API lookups to test
  hardcoded defaults without external API variance
- test_code_execution: Add notify_on_complete to blocked terminal params
- test_setup_openclaw_migration: Mock prompt_choice to select 'Full setup'
  (new quick-setup path leads to _require_tty → sys.exit in CI)
- test_skill_manager_tool: Patch get_all_skills_dirs alongside SKILLS_DIR
  so _find_skill searches tmp_path, not real ~/.hermes/skills/

**Missing attributes in object.__new__ test runners:**
- test_platform_reconnect: Add session_store to _make_runner()
- test_session_race_guard: Add hooks, _running_agents_ts, session_store,
  delivery_router to _make_runner()

**Production bug fix (gateway/run.py):**
- Fix sentinel eviction race: _AGENT_PENDING_SENTINEL was immediately
  evicted by the stale-detection logic because sentinels have no
  get_activity_summary() method, causing _stale_idle=inf >= timeout.
  Guard _should_evict with 'is not _AGENT_PENDING_SENTINEL'.

* fix: address remaining CI failures

- test_setup_openclaw_migration: Also mock _offer_launch_chat (called at
  end of both quick and full setup paths)
- test_code_execution: Move TERMINAL_ENV=local to module level to protect
  ALL test classes (TestEnvVarFiltering, TestExecuteCodeEdgeCases,
  TestInterruptHandling, TestHeadTailTruncation) from xdist env leaks
- test_matrix: Use try/except for nio.crypto imports (importorskip can be
  fooled by MagicMock in sys.modules under xdist)
2026-04-07 09:58:45 -07:00
Jeffrey Quesnelle f18a2aa634 Merge pull request #5880 from NousResearch/salvage/5752-nous-free-tier-gating
feat(nous): free-tier model gating and pricing in model selection (salvage #5752)
2026-04-07 12:37:09 -04:00
Teknium 47ddc2bde5 fix(nous): add 3-minute TTL cache to free-tier detection
check_nous_free_tier() now caches its result for 180 seconds to avoid
redundant Portal API calls during a session (auxiliary client init,
model selection, login flow all call it independently).

The TTL is short enough that an account upgrade from free to paid is
reflected within 3 minutes. clear_nous_free_tier_cache() is exposed
for explicit invalidation on login/logout.

Adds 4 tests for cache hit, TTL expiry, explicit clear, and TTL bound.
2026-04-07 09:30:26 -07:00
emozilla 29065cb9b5 feat(nous): free-tier model gating, pricing display, and vision fallback
- Show pricing during initial Nous Portal login (was missing from
  _login_nous, only shown in the already-logged-in hermes model path)

- Filter free models for paid subscribers: non-allowlisted free models
  are hidden; allowlisted models (xiaomi/mimo-v2-pro, xiaomi/mimo-v2-omni)
  only appear when actually priced as free

- Detect free-tier accounts via portal api/oauth/account endpoint
  (monthly_charge == 0); free-tier users see only free models as
  selectable, with paid models shown dimmed and unselectable

- Use xiaomi/mimo-v2-omni as the auxiliary vision model for free-tier
  Nous users so vision_analyze and browser_vision work without paid
  model access (replaces the default google/gemini-3-flash-preview)

- Unavailable models rendered via print() before TerminalMenu to avoid
  simple_term_menu line-width padding artifacts; upgrade URL resolved
  from auth state portal_base_url (supports staging/custom portals)

- Add 21 tests covering filter_nous_free_models, is_nous_free_tier,
  and partition_nous_models_by_tier
2026-04-07 09:21:48 -07:00
SHL0MS 902a02e3d5 Merge pull request #5791 from leotrs/manim-ce-reference-improvements
Expand Manim CE reference docs: geometry, animations, and LaTeX environments
2026-04-07 12:15:59 -04:00
Ben Barclay b2f477a30b feat: switch managed browser provider from Browserbase to Browser Use (#5750)
* feat: switch managed browser provider from Browserbase to Browser Use

The Nous subscription tool gateway now routes browser automation through
Browser Use instead of Browserbase. This commit:

- Adds managed Nous gateway support to BrowserUseProvider (idempotency
  keys, X-BB-API-Key auth header, external_call_id persistence)
- Removes managed gateway support from BrowserbaseProvider (now
  direct-only via BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID)
- Updates browser_tool.py fallback: prefers Browser Use over Browserbase
- Updates nous_subscription.py: gateway vendor 'browser-use', auto-config
  sets cloud_provider='browser-use' for new subscribers
- Updates tools_config.py: Nous Subscription entry now uses Browser Use
- Updates setup.py, cli.py, status.py, prompt_builder.py display strings
- Updates all affected tests to match new behavior

Browserbase remains fully functional for users with direct API credentials.
The change only affects the managed/subscription path.

* chore: remove redundant Browser Use hint from system prompt

* fix: upgrade Browser Use provider to v3 API

- Base URL: api/v2 -> api/v3 (v2 is legacy)
- Unified all endpoints to use native Browser Use paths:
  - POST /browsers (create session, returns cdpUrl)
  - PATCH /browsers/{id} with {action: stop} (close session)
- Removed managed-mode branching that used Browserbase-style
  /v1/sessions paths — v3 gateway now supports /browsers directly
- Removed unused managed_mode variable in close_session

* fix(browser-use): use X-Browser-Use-API-Key header for managed mode

The managed gateway expects X-Browser-Use-API-Key, not X-BB-API-Key
(which is a Browserbase-specific header). Using the wrong header caused
a 401 AUTH_ERROR on every managed-mode browser session create.

Simplified _headers() to always use X-Browser-Use-API-Key regardless
of direct vs managed mode.

* fix(nous_subscription): browserbase explicit provider is direct-only

Since managed Nous gateway now routes through Browser Use, the
browserbase explicit provider path should not check managed_browser_available
(which resolves against the browser-use gateway). Simplified to direct-only
with managed=False.

* fix(browser-use): port missing improvements from PR #5605

- CDP URL normalization: resolve HTTP discovery URLs to websocket after
  cloud provider create_session() (prevents agent-browser failures)
- Managed session payload: send timeout=5 and proxyCountryCode=us for
  gateway-backed sessions (prevents billing overruns)
- Update prompt builder, browser_close schema, and module docstring to
  replace remaining Browserbase references with Browser Use
- Dynamic /browser status detection via _get_cloud_provider() instead
  of hardcoded env var checks (future-proof for new providers)
- Rename post_setup key from 'browserbase' to 'agent_browser'
- Update setup hint to mention Browser Use alongside Browserbase
- Add tests: CDP normalization, browserbase direct-only guard,
  managed browser-use gateway, direct browserbase fallback

---------

Co-authored-by: rob-maron <132852777+rob-maron@users.noreply.github.com>
2026-04-07 08:40:22 -04:00
Leo Torres e8f6854cab docs: expand Manim CE reference docs with additional API coverage
Add geometry mobjects, movement/creation animations, and LaTeX
environments to the skill's reference docs. All verified against
Manim CE v0.20.1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:36:13 +02:00
322 changed files with 14675 additions and 1553 deletions
+3
View File
@@ -19,6 +19,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Install uv
uses: astral-sh/setup-uv@v5
-1
View File
@@ -15,7 +15,6 @@ Usage::
import asyncio
import logging
import os
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
-2
View File
@@ -262,8 +262,6 @@ class SessionManager:
if self._db_instance is not None:
return self._db_instance
try:
import os
from pathlib import Path
from hermes_state import SessionDB
hermes_home = get_hermes_home()
self._db_instance = SessionDB(db_path=hermes_home / "state.db")
+2 -88
View File
@@ -188,9 +188,7 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
if not base_url:
return False
normalized = base_url.rstrip("/").lower()
return normalized.startswith("https://api.minimax.io/anthropic") or normalized.startswith(
"https://api.minimaxi.com/anthropic"
)
return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
def build_anthropic_client(api_key: str, base_url: str = None):
@@ -708,29 +706,6 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
}
def run_hermes_oauth_login() -> Optional[str]:
"""Run Hermes-native OAuth PKCE flow for Claude Pro/Max subscription.
Opens a browser to claude.ai for authorization, prompts for the code,
exchanges it for tokens, and stores them in ~/.hermes/.anthropic_oauth.json.
Returns the access token on success, None on failure.
"""
result = run_hermes_oauth_login_pure()
if not result:
return None
access_token = result["access_token"]
refresh_token = result["refresh_token"]
expires_at_ms = result["expires_at_ms"]
_save_hermes_oauth_credentials(access_token, refresh_token, expires_at_ms)
_write_claude_code_credentials(access_token, refresh_token, expires_at_ms)
print("Authentication successful!")
return access_token
def _save_hermes_oauth_credentials(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
"""Save OAuth credentials to ~/.hermes/.anthropic_oauth.json."""
data = {
@@ -758,38 +733,6 @@ def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
return None
def refresh_hermes_oauth_token() -> Optional[str]:
"""Refresh the Hermes-managed OAuth token using the stored refresh token.
Returns the new access token, or None if refresh fails.
"""
creds = read_hermes_oauth_credentials()
if not creds or not creds.get("refreshToken"):
return None
try:
refreshed = refresh_anthropic_oauth_pure(
creds["refreshToken"],
use_json=True,
)
_save_hermes_oauth_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
logger.debug("Successfully refreshed Hermes OAuth token")
return refreshed["access_token"]
except Exception as e:
logger.debug("Failed to refresh Hermes OAuth token: %s", e)
return None
# ---------------------------------------------------------------------------
# Message / tool / response format conversion
# ---------------------------------------------------------------------------
@@ -847,7 +790,7 @@ def _convert_openai_image_part_to_anthropic(part: Dict[str, Any]) -> Optional[Di
},
}
if url.startswith("http://") or url.startswith("https://"):
if url.startswith(("http://", "https://")):
return {
"type": "image",
"source": {
@@ -859,35 +802,6 @@ def _convert_openai_image_part_to_anthropic(part: Dict[str, Any]) -> Optional[Di
return None
def _convert_user_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
block = {"type": "text", "text": part.get("text", "")}
if isinstance(part.get("cache_control"), dict):
block["cache_control"] = dict(part["cache_control"])
return block
if ptype == "image_url":
return _convert_openai_image_part_to_anthropic(part)
if ptype == "image" and part.get("source"):
return dict(part)
if ptype == "image" and part.get("data"):
media_type = part.get("mimeType") or part.get("media_type") or "image/png"
return {
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": part.get("data", ""),
},
}
if ptype == "tool_result":
return dict(part)
elif part is not None:
return {"type": "text", "text": str(part)}
return None
def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
"""Convert OpenAI tool definitions to Anthropic format."""
if not tools:
+48 -3
View File
@@ -91,6 +91,7 @@ auxiliary_is_nous: bool = False
# Default auxiliary models per provider
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
_NOUS_MODEL = "google/gemini-3-flash-preview"
_NOUS_FREE_TIER_VISION_MODEL = "xiaomi/mimo-v2-omni"
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
@@ -208,7 +209,6 @@ class _CodexCompletionsAdapter:
def create(self, **kwargs) -> Any:
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
temperature = kwargs.get("temperature")
# Separate system/instructions from conversation messages.
# Convert chat.completions multimodal content blocks to Responses
@@ -720,7 +720,19 @@ def _try_nous() -> Tuple[Optional[OpenAI], Optional[str]]:
global auxiliary_is_nous
auxiliary_is_nous = True
logger.debug("Auxiliary client: Nous Portal")
model = "gemini-3-flash" if nous.get("source") == "pool" else _NOUS_MODEL
if nous.get("source") == "pool":
model = "gemini-3-flash"
else:
model = _NOUS_MODEL
# Free-tier users can't use paid auxiliary models — use the free
# multimodal model instead so vision/browser-vision still works.
try:
from hermes_cli.models import check_nous_free_tier
if check_nous_free_tier():
model = _NOUS_FREE_TIER_VISION_MODEL
logger.debug("Free-tier Nous account — using %s for auxiliary/vision", model)
except Exception:
pass
return (
OpenAI(
api_key=_nous_api_key(nous),
@@ -1130,7 +1142,13 @@ def resolve_provider_client(
if provider == "codex":
provider = "openai-codex"
if provider == "main":
provider = "custom"
# Resolve to the user's actual main provider so named custom providers
# and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.
main_prov = _read_main_provider()
if main_prov and main_prov not in ("auto", "main", ""):
provider = main_prov
else:
provider = "custom"
# ── Auto: try all providers in priority order ────────────────────
if provider == "auto":
@@ -1226,6 +1244,28 @@ def resolve_provider_client(
"but no endpoint credentials found")
return None, None
# ── Named custom providers (config.yaml custom_providers list) ───
try:
from hermes_cli.runtime_provider import _get_named_custom_provider
custom_entry = _get_named_custom_provider(provider)
if custom_entry:
custom_base = custom_entry.get("base_url", "").strip()
custom_key = custom_entry.get("api_key", "").strip() or "no-key-required"
if custom_base:
final_model = model or _read_main_model() or "gpt-4o-mini"
client = OpenAI(api_key=custom_key, base_url=custom_base)
logger.debug(
"resolve_provider_client: named custom provider %r (%s)",
provider, final_model)
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
logger.warning(
"resolve_provider_client: named custom provider %r has no base_url",
provider)
return None, None
except ImportError:
pass
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
try:
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
@@ -1346,6 +1386,11 @@ def _normalize_vision_provider(provider: Optional[str]) -> str:
if provider == "codex":
return "openai-codex"
if provider == "main":
# Resolve to actual main provider — named custom providers and
# non-aggregator providers need to pass through as their real name.
main_prov = _read_main_provider()
if main_prov and main_prov not in ("auto", "main", ""):
return main_prov
return "custom"
return provider
+3 -2
View File
@@ -13,9 +13,10 @@ from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -92,7 +93,7 @@ class BuiltinMemoryProvider(MemoryProvider):
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
"""Not used — the memory tool is intercepted in run_agent.py."""
return json.dumps({"error": "Built-in memory tool is handled by the agent loop"})
return tool_error("Built-in memory tool is handled by the agent loop")
def shutdown(self) -> None:
"""No cleanup needed — files are saved on every write."""
+2 -3
View File
@@ -343,10 +343,9 @@ def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) -
def _ensure_reference_path_allowed(path: Path) -> None:
from hermes_constants import get_hermes_home
home = Path(os.path.expanduser("~")).resolve()
hermes_home = Path(
os.getenv("HERMES_HOME", str(home / ".hermes"))
).expanduser().resolve()
hermes_home = get_hermes_home().resolve()
blocked_exact = {home / rel for rel in _SENSITIVE_HOME_FILES}
blocked_exact.add(hermes_home / ".env")
+1 -4
View File
@@ -10,21 +10,18 @@ import uuid
import os
import re
from dataclasses import dataclass, fields, replace
from datetime import datetime, timezone
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
import hermes_cli.auth as auth_mod
from hermes_cli.auth import (
ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
PROVIDER_REGISTRY,
_agent_key_is_usable,
_codex_access_token_is_expiring,
_decode_jwt_claims,
_import_codex_cli_tokens,
_is_expiring,
_load_auth_store,
_load_provider_state,
_resolve_zai_base_url,
-18
View File
@@ -986,24 +986,6 @@ def _osc8_link(url: str, text: str) -> str:
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
def honcho_session_line(workspace: str, session_name: str) -> str:
"""One-line session indicator: `Honcho session: <clickable name>`."""
url = honcho_session_url(workspace, session_name)
linked_name = _osc8_link(url, f"{_SKY_BLUE}{session_name}{_ANSI_RESET}")
return f"{_DIM}Honcho session:{_ANSI_RESET} {linked_name}"
def write_tty(text: str) -> None:
"""Write directly to /dev/tty, bypassing stdout capture."""
try:
fd = os.open("/dev/tty", os.O_WRONLY)
os.write(fd, text.encode("utf-8"))
os.close(fd)
except OSError:
sys.stdout.write(text)
sys.stdout.flush()
# =========================================================================
# Context pressure display (CLI user-facing warnings)
# =========================================================================
+3 -2
View File
@@ -34,6 +34,7 @@ import re
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -249,7 +250,7 @@ class MemoryManager:
"""
provider = self._tool_to_provider.get(tool_name)
if provider is None:
return json.dumps({"error": f"No memory provider handles tool '{tool_name}'"})
return tool_error(f"No memory provider handles tool '{tool_name}'")
try:
return provider.handle_tool_call(tool_name, args, **kwargs)
except Exception as e:
@@ -257,7 +258,7 @@ class MemoryManager:
"Memory provider '%s' handle_tool_call(%s) failed: %s",
provider.name, tool_name, e,
)
return json.dumps({"error": f"Memory tool '{tool_name}' failed: {e}"})
return tool_error(f"Memory tool '{tool_name}' failed: {e}")
# -- Lifecycle hooks -----------------------------------------------------
+1 -1
View File
@@ -34,7 +34,7 @@ from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
logger = logging.getLogger(__name__)
+2 -2
View File
@@ -510,8 +510,8 @@ def fetch_endpoint_model_metadata(
def _get_context_cache_path() -> Path:
"""Return path to the persistent context length cache file."""
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
return hermes_home / "context_length_cache.yaml"
from hermes_constants import get_hermes_home
return get_hermes_home() / "context_length_cache.yaml"
def _load_context_cache() -> Dict[str, int]:
+5 -6
View File
@@ -23,9 +23,9 @@ import json
import logging
import os
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple
from utils import atomic_json_write
@@ -185,9 +185,8 @@ def _get_reverse_mapping() -> Dict[str, str]:
def _get_cache_path() -> Path:
"""Return path to disk cache file."""
env_val = os.environ.get("HERMES_HOME", "")
hermes_home = Path(env_val) if env_val else Path.home() / ".hermes"
return hermes_home / "models_dev_cache.json"
from hermes_constants import get_hermes_home
return get_hermes_home() / "models_dev_cache.json"
def _load_disk_cache() -> Dict[str, Any]:
@@ -231,7 +230,7 @@ def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
response = requests.get(MODELS_DEV_URL, timeout=15)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and len(data) > 0:
if isinstance(data, dict) and data:
_models_dev_cache = data
_models_dev_cache_time = time.time()
_save_disk_cache(data)
+1 -1
View File
@@ -10,7 +10,7 @@ import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from typing import Any, Dict, List, Set, Tuple
from hermes_constants import get_hermes_home
-1
View File
@@ -15,7 +15,6 @@ Inspired by Block/goose's SubdirectoryHintTracker.
import logging
import os
import re
import shlex
from pathlib import Path
from typing import Dict, Any, Optional, Set
+3 -1
View File
@@ -31,6 +31,8 @@ from multiprocessing import Pool, Lock
import traceback
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeRemainingColumn, MofNCompleteColumn
from rich.console import Console
logger = logging.getLogger(__name__)
import fire
from run_agent import AIAgent
@@ -1016,7 +1018,7 @@ class BatchRunner:
tool_stats = data.get('tool_stats', {})
# Check for invalid tool names (model hallucinations)
invalid_tools = [k for k in tool_stats.keys() if k not in VALID_TOOLS]
invalid_tools = [k for k in tool_stats if k not in VALID_TOOLS]
if invalid_tools:
filtered_entries += 1
+47 -41
View File
@@ -63,14 +63,14 @@ from agent.usage_pricing import (
format_duration_compact,
format_token_count_compact,
)
from hermes_cli.banner import _format_context_length
from hermes_cli.banner import _format_context_length, format_banner_version_label
_COMMAND_SPINNER_FRAMES = ("", "", "", "", "", "", "", "", "", "")
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
# User-managed env files should override stale shell exports on restart.
from hermes_constants import get_hermes_home, display_hermes_home, OPENROUTER_BASE_URL
from hermes_constants import get_hermes_home, display_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv
_hermes_home = get_hermes_home()
@@ -1036,21 +1036,44 @@ COMPACT_BANNER = """
def _build_compact_banner() -> str:
"""Build a compact banner that fits the current terminal width."""
w = min(shutil.get_terminal_size().columns - 2, 64)
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
except Exception:
_skin = None
skin_name = getattr(_skin, "name", "default") if _skin else "default"
border_color = _skin.get_color("banner_border", "#FFD700") if _skin else "#FFD700"
title_color = _skin.get_color("banner_title", "#FFBF00") if _skin else "#FFBF00"
dim_color = _skin.get_color("banner_dim", "#B8860B") if _skin else "#B8860B"
if skin_name == "default":
line1 = "⚕ NOUS HERMES - AI Agent Framework"
tiny_line = "⚕ NOUS HERMES"
else:
agent_name = _skin.get_branding("agent_name", "Hermes Agent") if _skin else "Hermes Agent"
line1 = f"{agent_name} - AI Agent Framework"
tiny_line = agent_name
version_line = format_banner_version_label()
w = min(shutil.get_terminal_size().columns - 2, 88)
if w < 30:
return "\n[#FFBF00]⚕ NOUS HERMES[/] [dim #B8860B]- Nous Research[/]\n"
return f"\n[{title_color}]{tiny_line}[/] [dim {dim_color}]- Nous Research[/]\n"
inner = w - 2 # inside the box border
bar = "" * w
line1 = "⚕ NOUS HERMES - AI Agent Framework"
line2 = "Messenger of the Digital Gods · Nous Research"
content_width = inner - 2
# Truncate and pad to fit
line1 = line1[:inner - 2].ljust(inner - 2)
line2 = line2[:inner - 2].ljust(inner - 2)
line1 = line1[:content_width].ljust(content_width)
line2 = version_line[:content_width].ljust(content_width)
return (
f"\n[bold #FFD700]╔{bar}╗[/]\n"
f"[bold #FFD700]║[/] [#FFBF00]{line1}[/] [bold #FFD700]║[/]\n"
f"[bold #FFD700]║[/] [dim #B8860B]{line2}[/] [bold #FFD700]║[/]\n"
f"[bold #FFD700]╚{bar}╝[/]\n"
f"\n[bold {border_color}]╔{bar}╗[/]\n"
f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n"
f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n"
f"[bold {border_color}]╚{bar}╝[/]\n"
)
@@ -2163,7 +2186,7 @@ class HermesCLI:
)
except Exception as exc:
message = format_runtime_provider_error(exc)
self.console.print(f"[bold red]{message}[/]")
ChatConsole().print(f"[bold red]{message}[/]")
return False
api_key = runtime.get("api_key")
@@ -2378,7 +2401,7 @@ class HermesCLI:
self._pending_title = None
return True
except Exception as e:
self.console.print(f"[bold red]Failed to initialize agent: {e}[/]")
ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
return False
def show_banner(self):
@@ -3536,13 +3559,6 @@ class HermesCLI:
_cprint(f" Original session: {parent_session_id}")
_cprint(f" Branch session: {new_session_id}")
def reset_conversation(self):
"""Reset the conversation by starting a new session."""
# Shut down memory provider before resetting — actual session boundary
if hasattr(self, 'agent') and self.agent:
self.agent.shutdown_memory_provider(self.conversation_history)
self.new_session()
def save_conversation(self):
"""Save the current conversation to a file."""
if not self.conversation_history:
@@ -4246,7 +4262,6 @@ class HermesCLI:
try:
config = load_gateway_config()
connected = config.get_connected_platforms()
print(" Messaging Platform Configuration:")
print(" " + "-" * 55)
@@ -4538,13 +4553,13 @@ class HermesCLI:
if output:
self.console.print(_rich_text_from_ansi(output))
else:
self.console.print("[dim]Command returned no output[/]")
ChatConsole().print("[dim]Command returned no output[/]")
except subprocess.TimeoutExpired:
self.console.print("[bold red]Quick command timed out (30s)[/]")
ChatConsole().print("[bold red]Quick command timed out (30s)[/]")
except Exception as e:
self.console.print(f"[bold red]Quick command error: {e}[/]")
ChatConsole().print(f"[bold red]Quick command error: {e}[/]")
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
elif qcmd.get("type") == "alias":
target = qcmd.get("target", "").strip()
if target:
@@ -4553,9 +4568,9 @@ class HermesCLI:
aliased_command = f"{target} {user_args}".strip()
return self.process_command(aliased_command)
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
# Check for plugin-registered slash commands
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
from hermes_cli.plugins import get_plugin_command_handler
@@ -4580,7 +4595,7 @@ class HermesCLI:
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
self.console.print(f"[bold red]Failed to load skill for {base_cmd}[/]")
ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]")
else:
# Prefix matching: if input uniquely identifies one command, execute it.
# Matches against both built-in COMMANDS and installed skill commands so
@@ -4641,14 +4656,14 @@ class HermesCLI:
)
if not msg:
self.console.print("[bold red]Failed to load the bundled /plan skill[/]")
ChatConsole().print("[bold red]Failed to load the bundled /plan skill[/]")
return
_cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}")
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
self.console.print("[bold red]Plan mode unavailable: input queue not initialized[/]")
ChatConsole().print("[bold red]Plan mode unavailable: input queue not initialized[/]")
def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session.
@@ -6008,7 +6023,7 @@ class HermesCLI:
timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120)
response_queue = queue.Queue()
is_open_ended = not choices or len(choices) == 0
is_open_ended = not choices
self._clarify_state = {
"question": question,
@@ -6291,14 +6306,6 @@ class HermesCLI:
except Exception:
pass
def _clear_current_input(self) -> None:
if getattr(self, "_app", None):
try:
self._app.current_buffer.text = ""
except Exception:
pass
def chat(self, message, images: list = None) -> Optional[str]:
"""
Send a message to the agent and get a response.
@@ -7839,7 +7846,6 @@ class HermesCLI:
title = '🔐 Sudo Password Required'
body = 'Enter password below (hidden), or press Enter to skip'
box_width = _panel_box_width(title, [body])
inner = max(0, box_width - 2)
lines = []
lines.append(('class:sudo-border', '╭─ '))
lines.append(('class:sudo-title', title))
+59 -12
View File
@@ -25,7 +25,6 @@ except ImportError:
import msvcrt
except ImportError:
msvcrt = None
import time
from pathlib import Path
from typing import Optional
@@ -159,6 +158,44 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
}
# Media extension sets — keep in sync with gateway/platforms/base.py:_process_message_background
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a'})
_VIDEO_EXTS = frozenset({'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'})
_IMAGE_EXTS = frozenset({'.jpg', '.jpeg', '.png', '.webp', '.gif'})
def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata: dict | None, loop, job: dict) -> None:
"""Send extracted MEDIA files as native platform attachments via a live adapter.
Routes each file to the appropriate adapter method (send_voice, send_image_file,
send_video, send_document) based on file extension — mirroring the routing logic
in ``BasePlatformAdapter._process_message_background``.
"""
from pathlib import Path
for media_path, _is_voice in media_files:
try:
ext = Path(media_path).suffix.lower()
if ext in _AUDIO_EXTS:
coro = adapter.send_voice(chat_id=chat_id, audio_path=media_path, metadata=metadata)
elif ext in _VIDEO_EXTS:
coro = adapter.send_video(chat_id=chat_id, video_path=media_path, metadata=metadata)
elif ext in _IMAGE_EXTS:
coro = adapter.send_image_file(chat_id=chat_id, image_path=media_path, metadata=metadata)
else:
coro = adapter.send_document(chat_id=chat_id, file_path=media_path, metadata=metadata)
future = asyncio.run_coroutine_threadsafe(coro, loop)
result = future.result(timeout=30)
if result and not getattr(result, "success", True):
logger.warning(
"Job '%s': media send failed for %s: %s",
job.get("id", "?"), media_path, getattr(result, "error", "unknown"),
)
except Exception as e:
logger.warning("Job '%s': failed to send media %s: %s", job.get("id", "?"), media_path, e)
def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None:
"""
Deliver job output to the configured target (origin chat, specific platform, etc.).
@@ -247,18 +284,28 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None:
if runtime_adapter is not None and loop is not None and getattr(loop, "is_running", lambda: False)():
send_metadata = {"thread_id": thread_id} if thread_id else None
try:
future = asyncio.run_coroutine_threadsafe(
runtime_adapter.send(chat_id, delivery_content, metadata=send_metadata),
loop,
)
send_result = future.result(timeout=60)
if send_result and not getattr(send_result, "success", True):
err = getattr(send_result, "error", "unknown")
logger.warning(
"Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone",
job["id"], platform_name, chat_id, err,
# Send cleaned text (MEDIA tags stripped) — not the raw content
text_to_send = cleaned_delivery_content.strip()
adapter_ok = True
if text_to_send:
future = asyncio.run_coroutine_threadsafe(
runtime_adapter.send(chat_id, text_to_send, metadata=send_metadata),
loop,
)
else:
send_result = future.result(timeout=60)
if send_result and not getattr(send_result, "success", True):
err = getattr(send_result, "error", "unknown")
logger.warning(
"Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone",
job["id"], platform_name, chat_id, err,
)
adapter_ok = False # fall through to standalone path
# Send extracted media files as native attachments via the live adapter
if adapter_ok and media_files:
_send_media_via_adapter(runtime_adapter, chat_id, media_files, send_metadata, loop, job)
if adapter_ok:
logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id)
return
except Exception as e:
@@ -44,7 +44,7 @@ import tempfile
import time
import uuid
from collections import defaultdict
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any, Dict, List, Optional, Tuple, Union
# Ensure repo root is on sys.path for imports
@@ -148,6 +148,62 @@ MODAL_INCOMPATIBLE_TASKS = {
# Tar extraction helper
# =============================================================================
def _normalize_tar_member_parts(member_name: str) -> list:
"""Return safe path components for a tar member or raise ValueError."""
normalized_name = member_name.replace("\\", "/")
posix_path = PurePosixPath(normalized_name)
windows_path = PureWindowsPath(member_name)
if (
not normalized_name
or posix_path.is_absolute()
or windows_path.is_absolute()
or windows_path.drive
):
raise ValueError(f"Unsafe archive member path: {member_name}")
parts = [part for part in posix_path.parts if part not in ("", ".")]
if not parts or any(part == ".." for part in parts):
raise ValueError(f"Unsafe archive member path: {member_name}")
return parts
def _safe_extract_tar(tar: tarfile.TarFile, target_dir: Path) -> None:
"""Extract a tar archive without allowing traversal or link entries."""
target_dir.mkdir(parents=True, exist_ok=True)
target_root = target_dir.resolve()
for member in tar.getmembers():
parts = _normalize_tar_member_parts(member.name)
target = target_dir.joinpath(*parts)
target_real = target.resolve(strict=False)
try:
target_real.relative_to(target_root)
except ValueError as exc:
raise ValueError(f"Unsafe archive member path: {member.name}") from exc
if member.isdir():
target_real.mkdir(parents=True, exist_ok=True)
continue
if not member.isfile():
raise ValueError(f"Unsupported archive member type: {member.name}")
target_real.parent.mkdir(parents=True, exist_ok=True)
extracted = tar.extractfile(member)
if extracted is None:
raise ValueError(f"Cannot read archive member: {member.name}")
with extracted, open(target_real, "wb") as dst:
shutil.copyfileobj(extracted, dst)
try:
os.chmod(target_real, member.mode & 0o777)
except OSError:
pass
def _extract_base64_tar(b64_data: str, target_dir: Path):
"""Extract a base64-encoded tar.gz archive into target_dir."""
if not b64_data:
@@ -155,7 +211,7 @@ def _extract_base64_tar(b64_data: str, target_dir: Path):
raw = base64.b64decode(b64_data)
buf = io.BytesIO(raw)
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
tar.extractall(path=str(target_dir))
_safe_extract_tar(tar, target_dir)
# =============================================================================
+2 -1
View File
@@ -24,7 +24,8 @@ from pathlib import Path
logger = logging.getLogger("hooks.boot-md")
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
from hermes_constants import get_hermes_home
HERMES_HOME = get_hermes_home()
BOOT_FILE = HERMES_HOME / "BOOT.md"
-1
View File
@@ -124,7 +124,6 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
def _build_slack(adapter) -> List[Dict[str, str]]:
"""List Slack channels the bot has joined."""
channels = []
# Slack adapter may expose a web client
client = getattr(adapter, "_app", None) or getattr(adapter, "_client", None)
if not client:
+14
View File
@@ -556,6 +556,18 @@ def load_gateway_config() -> GatewayConfig:
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
# ignored_channels: channels where bot never responds (even when mentioned)
ic = discord_cfg.get("ignored_channels")
if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
if isinstance(ic, list):
ic = ",".join(str(v) for v in ic)
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
# no_thread_channels: channels where bot responds directly without creating thread
ntc = discord_cfg.get("no_thread_channels")
if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
if isinstance(ntc, list):
ntc = ",".join(str(v) for v in ntc)
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
# Telegram settings → env vars (env vars take precedence)
telegram_cfg = yaml_cfg.get("telegram", {})
@@ -570,6 +582,8 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
if isinstance(whatsapp_cfg, dict):
+1 -35
View File
@@ -314,38 +314,4 @@ def parse_deliver_spec(
return deliver
def build_delivery_context_for_tool(
config: GatewayConfig,
origin: Optional[SessionSource] = None
) -> Dict[str, Any]:
"""
Build context for the unified cronjob tool to understand delivery options.
This is passed to the tool so it can validate and explain delivery targets.
"""
connected = config.get_connected_platforms()
options = {
"origin": {
"description": "Back to where this job was created",
"available": origin is not None,
},
"local": {
"description": "Save to local files only",
"available": True,
}
}
for platform in connected:
home = config.get_home_channel(platform)
options[platform.value] = {
"description": f"{platform.value.title()} home channel",
"available": True,
"home_channel": home.to_dict() if home else None,
}
return {
"origin": origin.to_dict() if origin else None,
"options": options,
"always_log_local": config.always_log_local,
}
+63 -5
View File
@@ -20,6 +20,7 @@ Requires:
"""
import asyncio
import hmac
import json
import logging
import os
@@ -370,7 +371,7 @@ class APIServerAdapter(BasePlatformAdapter):
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
if token == self._api_key:
if hmac.compare_digest(token, self._api_key):
return None # Auth OK
return web.json_response(
@@ -563,8 +564,10 @@ class APIServerAdapter(BasePlatformAdapter):
if delta is not None:
_stream_q.put(delta)
def _on_tool_progress(name, preview, args):
def _on_tool_progress(event_type, name, preview, args, **kwargs):
"""Inject tool progress into the SSE stream for Open WebUI."""
if event_type != "tool.started":
return # Only show tool start events in chat stream
if name.startswith("_"):
return # Skip internal events (_thinking)
from agent.display import get_tool_emoji
@@ -815,9 +818,29 @@ class APIServerAdapter(BasePlatformAdapter):
else:
return web.json_response(_openai_error("'input' must be a string or array"), status=400)
# Reconstruct conversation history from previous_response_id
# Accept explicit conversation_history from the request body.
# This lets stateless clients supply their own history instead of
# relying on server-side response chaining via previous_response_id.
# Precedence: explicit conversation_history > previous_response_id.
conversation_history: List[Dict[str, str]] = []
if previous_response_id:
raw_history = body.get("conversation_history")
if raw_history:
if not isinstance(raw_history, list):
return web.json_response(
_openai_error("'conversation_history' must be an array of message objects"),
status=400,
)
for i, entry in enumerate(raw_history):
if not isinstance(entry, dict) or "role" not in entry or "content" not in entry:
return web.json_response(
_openai_error(f"conversation_history[{i}] must have 'role' and 'content' fields"),
status=400,
)
conversation_history.append({"role": str(entry["role"]), "content": str(entry["content"])})
if previous_response_id:
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
if not conversation_history and previous_response_id:
stored = self._response_store.get(previous_response_id)
if stored is None:
return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404)
@@ -1403,14 +1426,49 @@ class APIServerAdapter(BasePlatformAdapter):
instructions = body.get("instructions")
previous_response_id = body.get("previous_response_id")
# Accept explicit conversation_history from the request body.
# Precedence: explicit conversation_history > previous_response_id.
conversation_history: List[Dict[str, str]] = []
if previous_response_id:
raw_history = body.get("conversation_history")
if raw_history:
if not isinstance(raw_history, list):
return web.json_response(
_openai_error("'conversation_history' must be an array of message objects"),
status=400,
)
for i, entry in enumerate(raw_history):
if not isinstance(entry, dict) or "role" not in entry or "content" not in entry:
return web.json_response(
_openai_error(f"conversation_history[{i}] must have 'role' and 'content' fields"),
status=400,
)
conversation_history.append({"role": str(entry["role"]), "content": str(entry["content"])})
if previous_response_id:
logger.debug("Both conversation_history and previous_response_id provided; using conversation_history")
if not conversation_history and previous_response_id:
stored = self._response_store.get(previous_response_id)
if stored:
conversation_history = list(stored.get("conversation_history", []))
if instructions is None:
instructions = stored.get("instructions")
# When input is a multi-message array, extract all but the last
# message as conversation history (the last becomes user_message).
# Only fires when no explicit history was provided.
if not conversation_history and isinstance(raw_input, list) and len(raw_input) > 1:
for msg in raw_input[:-1]:
if isinstance(msg, dict) and msg.get("role") and msg.get("content"):
content = msg["content"]
if isinstance(content, list):
# Flatten multi-part content blocks to text
content = " ".join(
part.get("text", "") for part in content
if isinstance(part, dict) and part.get("type") == "text"
)
conversation_history.append({"role": msg["role"], "content": str(content)})
session_id = body.get("session_id") or run_id
ephemeral_system_prompt = instructions
+55 -7
View File
@@ -27,7 +27,6 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
from gateway.session import SessionSource, build_session_key
from hermes_cli.config import get_hermes_home
from hermes_constants import get_hermes_dir
@@ -125,7 +124,14 @@ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) ->
Returns:
Absolute path to the cached image file as a string.
Raises:
ValueError: If the URL targets a private/internal network (SSRF protection).
"""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {_safe_url_for_log(url)}")
import asyncio
import httpx
import logging as _logging
@@ -233,7 +239,14 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
Returns:
Absolute path to the cached audio file as a string.
Raises:
ValueError: If the URL targets a private/internal network (SSRF protection).
"""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {_safe_url_for_log(url)}")
import asyncio
import httpx
import logging as _logging
@@ -485,6 +498,9 @@ class BasePlatformAdapter(ABC):
self._background_tasks: set[asyncio.Task] = set()
# Chats where auto-TTS on voice input is disabled (set by /voice off)
self._auto_tts_disabled_chats: set = set()
# Chats where typing indicator is paused (e.g. during approval waits).
# _keep_typing skips send_typing when the chat_id is in this set.
self._typing_paused: set = set()
@property
def has_fatal_error(self) -> bool:
@@ -944,10 +960,16 @@ class BasePlatformAdapter(ABC):
Telegram/Discord typing status expires after ~5 seconds, so we refresh every 2
to recover quickly after progress messages interrupt it.
Skips send_typing when the chat is in ``_typing_paused`` (e.g. while
the agent is waiting for dangerous-command approval). This is critical
for Slack's Assistant API where ``assistant_threads_setStatus`` disables
the compose box pausing lets the user type ``/approve`` or ``/deny``.
"""
try:
while True:
await self.send_typing(chat_id, metadata=metadata)
if chat_id not in self._typing_paused:
await self.send_typing(chat_id, metadata=metadata)
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass # Normal cancellation when handler completes
@@ -961,7 +983,20 @@ class BasePlatformAdapter(ABC):
await self.stop_typing(chat_id)
except Exception:
pass
self._typing_paused.discard(chat_id)
def pause_typing_for_chat(self, chat_id: str) -> None:
"""Pause typing indicator for a chat (e.g. during approval waits).
Thread-safe (CPython GIL) can be called from the sync agent thread
while ``_keep_typing`` runs on the async event loop.
"""
self._typing_paused.add(chat_id)
def resume_typing_for_chat(self, chat_id: str) -> None:
"""Resume typing indicator for a chat after approval resolves."""
self._typing_paused.discard(chat_id)
# ── Processing lifecycle hooks ──────────────────────────────────────────
# Subclasses override these to react to message processing events
# (e.g. Discord adds 👀/✅/❌ reactions).
@@ -1084,6 +1119,22 @@ class BasePlatformAdapter(ABC):
logger.error("[%s] Fallback send also failed: %s", self.name, fallback_result.error)
return fallback_result
@staticmethod
def _merge_caption(existing_text: Optional[str], new_text: str) -> str:
"""Merge a new caption into existing text, avoiding duplicates.
Uses line-by-line exact match (not substring) to prevent false positives
where a shorter caption is silently dropped because it appears as a
substring of a longer one (e.g. "Meeting" inside "Meeting agenda").
Whitespace is normalised for comparison.
"""
if not existing_text:
return new_text
existing_captions = [c.strip() for c in existing_text.split("\n\n")]
if new_text.strip() not in existing_captions:
return f"{existing_text}\n\n{new_text}".strip()
return existing_text
async def handle_message(self, event: MessageEvent) -> None:
"""
Process an incoming message.
@@ -1143,10 +1194,7 @@ class BasePlatformAdapter(ABC):
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
if event.text:
if not existing.text:
existing.text = event.text
elif event.text not in existing.text:
existing.text = f"{existing.text}\n\n{event.text}".strip()
existing.text = self._merge_caption(existing.text, event.text)
else:
self._pending_messages[session_key] = event
return # Don't interrupt now - will run after current task completes
+23 -3
View File
@@ -55,6 +55,7 @@ from gateway.platforms.base import (
cache_document_from_bytes,
SUPPORTED_DOCUMENT_TYPES,
)
from tools.url_safety import is_safe_url
def _clean_discord_id(entry: str) -> str:
@@ -1285,6 +1286,10 @@ class DiscordAdapter(BasePlatformAdapter):
if not self._client:
return SendResult(success=False, error="Not connected")
if not is_safe_url(image_url):
logger.warning("[%s] Blocked unsafe image URL during Discord send_image", self.name)
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
try:
import aiohttp
@@ -2188,9 +2193,11 @@ class DiscordAdapter(BasePlatformAdapter):
# UNLESS the channel is in the free-response list or the message is
# in a thread where the bot has already participated.
#
# Config (all settable via discord.* in config.yaml):
# Config (all settable via discord.* in config.yaml or DISCORD_* env vars):
# discord.require_mention: Require @mention in server channels (default: true)
# discord.free_response_channels: Channel IDs where bot responds without mention
# discord.ignored_channels: Channel IDs where bot NEVER responds (even when mentioned)
# discord.no_thread_channels: Channel IDs where bot responds directly without creating thread
# discord.auto_thread: Auto-create thread on @mention in channels (default: true)
thread_id = None
@@ -2201,9 +2208,18 @@ class DiscordAdapter(BasePlatformAdapter):
parent_channel_id = self._get_parent_channel_id(message.channel)
if not isinstance(message.channel, discord.DMChannel):
# Check ignored channels first - never respond even when mentioned
ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "")
ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()}
channel_ids = {str(message.channel.id)}
if parent_channel_id:
channel_ids.add(parent_channel_id)
if channel_ids & ignored_channels:
logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids)
return
free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
channel_ids = {str(message.channel.id)}
if parent_channel_id:
channel_ids.add(parent_channel_id)
@@ -2225,10 +2241,14 @@ class DiscordAdapter(BasePlatformAdapter):
# Auto-thread: when enabled, automatically create a thread for every
# @mention in a text channel so each conversation is isolated (like Slack).
# Messages already inside threads or DMs are unaffected.
# no_thread_channels: channels where bot responds directly without thread.
auto_threaded_channel = None
if not is_thread and not isinstance(message.channel, discord.DMChannel):
no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
skip_thread = bool(channel_ids & no_thread_channels)
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
if auto_thread:
if auto_thread and not skip_thread:
thread = await self._auto_create_thread(message)
if thread:
is_thread = True
+5 -9
View File
@@ -60,7 +60,6 @@ try:
CreateMessageRequestBody,
GetChatRequest,
GetMessageRequest,
GetImageRequest,
GetMessageResourceRequest,
P2ImMessageMessageReadV1,
ReplyMessageRequest,
@@ -388,10 +387,6 @@ def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int:
return default if parsed is None else parsed
def _is_loop_ready(loop: Optional[asyncio.AbstractEventLoop]) -> bool:
return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)())
# ---------------------------------------------------------------------------
# Post payload builders and parsers
# ---------------------------------------------------------------------------
@@ -2070,10 +2065,7 @@ class FeishuAdapter(BasePlatformAdapter):
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
if event.text:
if not existing.text:
existing.text = event.text
elif event.text not in existing.text.split("\n\n"):
existing.text = f"{existing.text}\n\n{event.text}"
existing.text = self._merge_caption(existing.text, event.text)
existing.timestamp = event.timestamp
if event.message_id:
existing.message_id = event.message_id
@@ -2117,6 +2109,10 @@ class FeishuAdapter(BasePlatformAdapter):
default_ext: str,
preferred_name: str,
) -> tuple[str, str]:
from tools.url_safety import is_safe_url
if not is_safe_url(file_url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {file_url[:80]}")
import httpx
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
+6 -1
View File
@@ -586,6 +586,11 @@ class MatrixAdapter(BasePlatformAdapter):
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Download an image URL and upload it to Matrix."""
from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("Matrix: blocked unsafe image URL (SSRF protection)")
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
try:
# Try aiohttp first (always available), fall back to httpx
try:
@@ -1057,7 +1062,7 @@ class MatrixAdapter(BasePlatformAdapter):
# Message type.
msg_type = MessageType.TEXT
if body.startswith("!") or body.startswith("/"):
if body.startswith(("!", "/")):
msg_type = MessageType.COMMAND
source = self.build_source(
+5 -1
View File
@@ -407,6 +407,11 @@ class MattermostAdapter(BasePlatformAdapter):
kind: str = "file",
) -> SendResult:
"""Download a URL and upload it as a file attachment."""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
logger.warning("Mattermost: blocked unsafe URL (SSRF protection)")
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
import asyncio
import aiohttp
@@ -430,7 +435,6 @@ class MattermostAdapter(BasePlatformAdapter):
ct = resp.content_type or "application/octet-stream"
break
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
last_exc = exc
if attempt < 2:
await asyncio.sleep(1.5 * (attempt + 1))
continue
+334 -53
View File
@@ -84,6 +84,17 @@ class SlackAdapter(BasePlatformAdapter):
self._seen_messages: Dict[str, float] = {}
self._SEEN_TTL = 300 # 5 minutes
self._SEEN_MAX = 2000 # prune threshold
# Track pending approval message_ts → resolved flag to prevent
# double-clicks on approval buttons.
self._approval_resolved: Dict[str, bool] = {}
# Track timestamps of messages sent by the bot so we can respond
# to thread replies even without an explicit @mention.
self._bot_message_ts: set = set()
self._BOT_TS_MAX = 5000 # cap to avoid unbounded growth
# Track threads where the bot has been @mentioned — once mentioned,
# respond to ALL subsequent messages in that thread automatically.
self._mentioned_threads: set = set()
self._MENTIONED_THREADS_MAX = 5000
async def connect(self) -> bool:
"""Connect to Slack via Socket Mode."""
@@ -176,6 +187,15 @@ class SlackAdapter(BasePlatformAdapter):
await ack()
await self._handle_slash_command(command)
# Register Block Kit action handlers for approval buttons
for _action_id in (
"hermes_approve_once",
"hermes_approve_session",
"hermes_approve_always",
"hermes_deny",
):
self._app.action(_action_id)(self._handle_approval_action)
# Start Socket Mode handler in background
self._handler = AsyncSocketModeHandler(self._app, app_token)
self._socket_mode_task = asyncio.create_task(self._handler.start_async())
@@ -256,9 +276,22 @@ class SlackAdapter(BasePlatformAdapter):
last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)
# Track the sent message ts so we can auto-respond to thread
# replies without requiring @mention.
sent_ts = last_result.get("ts") if last_result else None
if sent_ts:
self._bot_message_ts.add(sent_ts)
# Also register the thread root so replies-to-my-replies work
if thread_ts:
self._bot_message_ts.add(thread_ts)
if len(self._bot_message_ts) > self._BOT_TS_MAX:
excess = len(self._bot_message_ts) - self._BOT_TS_MAX // 2
for old_ts in list(self._bot_message_ts)[:excess]:
self._bot_message_ts.discard(old_ts)
return SendResult(
success=True,
message_id=last_result.get("ts") if last_result else None,
message_id=sent_ts,
raw_response=last_result,
)
@@ -562,6 +595,11 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return SendResult(success=False, error="Not connected")
from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("[Slack] Blocked unsafe image URL (SSRF protection)")
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
try:
import httpx
@@ -766,30 +804,61 @@ class SlackAdapter(BasePlatformAdapter):
else:
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
# In channels, only respond if bot is mentioned OR if this is a
# reply in a thread where the bot has an active session.
# In channels, respond if:
# 1. The bot is @mentioned in this message, OR
# 2. The message is a reply in a thread the bot started/participated in, OR
# 3. The message is in a thread where the bot was previously @mentioned, OR
# 4. There's an existing session for this thread (survives restarts)
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
is_mentioned = bot_uid and f"<@{bot_uid}>" in text
event_thread_ts = event.get("thread_ts")
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
if not is_dm and bot_uid and not is_mentioned:
# Check if this is a thread reply (thread_ts exists and differs from ts)
event_thread_ts = event.get("thread_ts")
is_thread_reply = event_thread_ts and event_thread_ts != ts
if is_thread_reply and self._has_active_session_for_thread(
channel_id=channel_id,
thread_ts=event_thread_ts,
user_id=user_id,
):
# Allow thread replies without mention if there's an active session
pass
else:
# Not a thread reply or no active session - ignore
reply_to_bot_thread = (
is_thread_reply and event_thread_ts in self._bot_message_ts
)
in_mentioned_thread = (
event_thread_ts is not None
and event_thread_ts in self._mentioned_threads
)
has_session = (
is_thread_reply
and self._has_active_session_for_thread(
channel_id=channel_id,
thread_ts=event_thread_ts,
user_id=user_id,
)
)
if not reply_to_bot_thread and not in_mentioned_thread and not has_session:
return
if is_mentioned:
# Strip the bot mention from the text
text = text.replace(f"<@{bot_uid}>", "").strip()
# Register this thread so all future messages auto-trigger the bot
if event_thread_ts:
self._mentioned_threads.add(event_thread_ts)
if len(self._mentioned_threads) > self._MENTIONED_THREADS_MAX:
to_remove = list(self._mentioned_threads)[:self._MENTIONED_THREADS_MAX // 2]
for t in to_remove:
self._mentioned_threads.discard(t)
# When entering a thread for the first time (no existing session),
# fetch thread context so the agent understands the conversation.
if is_thread_reply and not self._has_active_session_for_thread(
channel_id=channel_id,
thread_ts=event_thread_ts,
user_id=user_id,
):
thread_context = await self._fetch_thread_context(
channel_id=channel_id,
thread_ts=event_thread_ts,
current_ts=ts,
team_id=team_id,
)
if thread_context:
text = thread_context + text
# Determine message type
msg_type = MessageType.TEXT
@@ -912,6 +981,233 @@ class SlackAdapter(BasePlatformAdapter):
await self._remove_reaction(channel_id, ts, "eyes")
await self._add_reaction(channel_id, ts, "white_check_mark")
# ----- Approval button support (Block Kit) -----
async def send_exec_approval(
self, chat_id: str, command: str, session_key: str,
description: str = "dangerous command",
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a Block Kit approval prompt with interactive buttons.
The buttons call ``resolve_gateway_approval()`` to unblock the waiting
agent thread same mechanism as the text ``/approve`` flow.
"""
if not self._app:
return SendResult(success=False, error="Not connected")
try:
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
thread_ts = self._resolve_thread_ts(None, metadata)
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f":warning: *Command Approval Required*\n"
f"```{cmd_preview}```\n"
f"Reason: {description}"
),
},
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Allow Once"},
"style": "primary",
"action_id": "hermes_approve_once",
"value": session_key,
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Allow Session"},
"action_id": "hermes_approve_session",
"value": session_key,
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Always Allow"},
"action_id": "hermes_approve_always",
"value": session_key,
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Deny"},
"style": "danger",
"action_id": "hermes_deny",
"value": session_key,
},
],
},
]
kwargs: Dict[str, Any] = {
"channel": chat_id,
"text": f"⚠️ Command approval required: {cmd_preview[:100]}",
"blocks": blocks,
}
if thread_ts:
kwargs["thread_ts"] = thread_ts
result = await self._get_client(chat_id).chat_postMessage(**kwargs)
msg_ts = result.get("ts", "")
if msg_ts:
self._approval_resolved[msg_ts] = False
return SendResult(success=True, message_id=msg_ts, raw_response=result)
except Exception as e:
logger.error("[Slack] send_exec_approval failed: %s", e, exc_info=True)
return SendResult(success=False, error=str(e))
async def _handle_approval_action(self, ack, body, action) -> None:
"""Handle an approval button click from Block Kit."""
await ack()
action_id = action.get("action_id", "")
session_key = action.get("value", "")
message = body.get("message", {})
msg_ts = message.get("ts", "")
channel_id = body.get("channel", {}).get("id", "")
user_name = body.get("user", {}).get("name", "unknown")
# Map action_id to approval choice
choice_map = {
"hermes_approve_once": "once",
"hermes_approve_session": "session",
"hermes_approve_always": "always",
"hermes_deny": "deny",
}
choice = choice_map.get(action_id, "deny")
# Prevent double-clicks
if self._approval_resolved.get(msg_ts, False):
return
self._approval_resolved[msg_ts] = True
# Update the message to show the decision and remove buttons
label_map = {
"once": f"✅ Approved once by {user_name}",
"session": f"✅ Approved for session by {user_name}",
"always": f"✅ Approved permanently by {user_name}",
"deny": f"❌ Denied by {user_name}",
}
decision_text = label_map.get(choice, f"Resolved by {user_name}")
# Get original text from the section block
original_text = ""
for block in message.get("blocks", []):
if block.get("type") == "section":
original_text = block.get("text", {}).get("text", "")
break
updated_blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": original_text or "Command approval request",
},
},
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": decision_text},
],
},
]
try:
await self._get_client(channel_id).chat_update(
channel=channel_id,
ts=msg_ts,
text=decision_text,
blocks=updated_blocks,
)
except Exception as e:
logger.warning("[Slack] Failed to update approval message: %s", e)
# Resolve the approval — this unblocks the agent thread
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(session_key, choice)
logger.info(
"Slack button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, session_key, choice, user_name,
)
except Exception as exc:
logger.error("Failed to resolve gateway approval from Slack button: %s", exc)
# Clean up stale approval state
self._approval_resolved.pop(msg_ts, None)
# ----- Thread context fetching -----
async def _fetch_thread_context(
self, channel_id: str, thread_ts: str, current_ts: str,
team_id: str = "", limit: int = 30,
) -> str:
"""Fetch recent thread messages to provide context when the bot is
mentioned mid-thread for the first time.
Returns a formatted string with thread history, or empty string on
failure or if the thread is empty (just the parent message).
"""
try:
client = self._get_client(channel_id)
result = await client.conversations_replies(
channel=channel_id,
ts=thread_ts,
limit=limit + 1, # +1 because it includes the current message
inclusive=True,
)
messages = result.get("messages", [])
if not messages:
return ""
context_parts = []
for msg in messages:
msg_ts = msg.get("ts", "")
# Skip the current message (the one that triggered this fetch)
if msg_ts == current_ts:
continue
# Skip bot messages from ourselves
if msg.get("bot_id") or msg.get("subtype") == "bot_message":
continue
msg_user = msg.get("user", "unknown")
msg_text = msg.get("text", "").strip()
if not msg_text:
continue
# Strip bot mentions from context messages
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
if bot_uid:
msg_text = msg_text.replace(f"<@{bot_uid}>", "").strip()
# Mark the thread parent
is_parent = msg_ts == thread_ts
prefix = "[thread parent] " if is_parent else ""
# Resolve user name (cached)
name = await self._resolve_user_name(msg_user, chat_id=channel_id)
context_parts.append(f"{prefix}{name}: {msg_text}")
if not context_parts:
return ""
return (
"[Thread context — previous messages in this thread:]\n"
+ "\n".join(context_parts)
+ "\n[End of thread context]\n\n"
)
except Exception as e:
logger.warning("[Slack] Failed to fetch thread context: %s", e)
return ""
async def _handle_slash_command(self, command: dict) -> None:
"""Handle /hermes slash command."""
text = command.get("text", "").strip()
@@ -960,27 +1256,22 @@ class SlackAdapter(BasePlatformAdapter):
user_id: str,
) -> bool:
"""Check if there's an active session for a thread.
Used to determine if thread replies without @mentions should be
processed (they should if there's an active session).
Args:
channel_id: The Slack channel ID
thread_ts: The thread timestamp (parent message ts)
user_id: The user ID of the sender
Returns:
True if there's an active session for this thread
Uses ``build_session_key()`` as the single source of truth for key
construction avoids the bug where manual key building didn't
respect ``thread_sessions_per_user`` and ``group_sessions_per_user``
settings correctly.
"""
session_store = getattr(self, "_session_store", None)
if not session_store:
return False
try:
# Build a SessionSource for this thread
from gateway.session import SessionSource
from gateway.config import Platform
from gateway.session import SessionSource, build_session_key
source = SessionSource(
platform=Platform.SLACK,
chat_id=channel_id,
@@ -988,31 +1279,21 @@ class SlackAdapter(BasePlatformAdapter):
user_id=user_id,
thread_id=thread_ts,
)
# Generate the session key using the same logic as SessionStore
# This mirrors the logic in build_session_key for group sessions
key_parts = ["agent:main", "slack", "group", channel_id, thread_ts]
# Include user_id if group_sessions_per_user is enabled
# We check the session store config if available
group_sessions_per_user = getattr(
session_store, "config", {}
# Read session isolation settings from the store's config
store_cfg = getattr(session_store, "config", None)
gspu = getattr(store_cfg, "group_sessions_per_user", True) if store_cfg else True
tspu = getattr(store_cfg, "thread_sessions_per_user", False) if store_cfg else False
session_key = build_session_key(
source,
group_sessions_per_user=gspu,
thread_sessions_per_user=tspu,
)
if hasattr(group_sessions_per_user, "group_sessions_per_user"):
group_sessions_per_user = group_sessions_per_user.group_sessions_per_user
else:
group_sessions_per_user = True # Default
if group_sessions_per_user and user_id:
key_parts.append(str(user_id))
session_key = ":".join(key_parts)
# Check if the session exists in the store
session_store._ensure_loaded()
return session_key in session_store._entries
except Exception:
# If anything goes wrong, default to False (require mention)
return False
async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str:
+168 -11
View File
@@ -153,6 +153,8 @@ class TelegramAdapter(BasePlatformAdapter):
self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", [])
# Interactive model picker state per chat
self._model_picker_state: Dict[str, dict] = {}
# Approval button state: message_id → session_key
self._approval_state: Dict[int, str] = {}
def _fallback_ips(self) -> list[str]:
"""Return validated fallback IPs from config (populated by _apply_env_overrides)."""
@@ -1010,6 +1012,70 @@ class TelegramAdapter(BasePlatformAdapter):
logger.warning("[%s] send_update_prompt failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_exec_approval(
self, chat_id: str, command: str, session_key: str,
description: str = "dangerous command",
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an inline-keyboard approval prompt with interactive buttons.
The buttons call ``resolve_gateway_approval()`` to unblock the waiting
agent thread same mechanism as the text ``/approve`` flow.
"""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
cmd_preview = command[:3800] + "..." if len(command) > 3800 else command
text = (
f"⚠️ *Command Approval Required*\n\n"
f"`{cmd_preview}`\n\n"
f"Reason: {description}"
)
# Resolve thread context for thread replies
thread_id = None
if metadata:
thread_id = metadata.get("thread_id") or metadata.get("message_thread_id")
# We'll use the message_id as part of callback_data to look up session_key
# Send a placeholder first, then update — or use a counter.
# Simpler: use a monotonic counter to generate short IDs.
import itertools
if not hasattr(self, "_approval_counter"):
self._approval_counter = itertools.count(1)
approval_id = next(self._approval_counter)
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✅ Allow Once", callback_data=f"ea:once:{approval_id}"),
InlineKeyboardButton("✅ Session", callback_data=f"ea:session:{approval_id}"),
],
[
InlineKeyboardButton("✅ Always", callback_data=f"ea:always:{approval_id}"),
InlineKeyboardButton("❌ Deny", callback_data=f"ea:deny:{approval_id}"),
],
])
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"text": text,
"parse_mode": ParseMode.MARKDOWN,
"reply_markup": keyboard,
}
if thread_id:
kwargs["message_thread_id"] = int(thread_id)
msg = await self._bot.send_message(**kwargs)
# Store session_key keyed by approval_id for the callback handler
self._approval_state[approval_id] = session_key
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] send_exec_approval failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_model_picker(
self,
chat_id: str,
@@ -1321,6 +1387,56 @@ class TelegramAdapter(BasePlatformAdapter):
await self._handle_model_picker_callback(query, data, chat_id)
return
# --- Exec approval callbacks (ea:choice:id) ---
if data.startswith("ea:"):
parts = data.split(":", 2)
if len(parts) == 3:
choice = parts[1] # once, session, always, deny
try:
approval_id = int(parts[2])
except (ValueError, IndexError):
await query.answer(text="Invalid approval data.")
return
session_key = self._approval_state.pop(approval_id, None)
if not session_key:
await query.answer(text="This approval has already been resolved.")
return
# Map choice to human-readable label
label_map = {
"once": "✅ Approved once",
"session": "✅ Approved for session",
"always": "✅ Approved permanently",
"deny": "❌ Denied",
}
user_display = getattr(query.from_user, "first_name", "User")
label = label_map.get(choice, "Resolved")
await query.answer(text=label)
# Edit message to show decision, remove buttons
try:
await query.edit_message_text(
text=f"{label} by {user_display}",
parse_mode=ParseMode.MARKDOWN,
reply_markup=None,
)
except Exception:
pass # non-fatal if edit fails
# Resolve the approval — unblocks the agent thread
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(session_key, choice)
logger.info(
"Telegram button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, session_key, choice, user_display,
)
except Exception as exc:
logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
return
# --- Update prompt callbacks ---
if not data.startswith("update_prompt:"):
return
@@ -1369,7 +1485,7 @@ class TelegramAdapter(BasePlatformAdapter):
with open(audio_path, "rb") as audio_file:
# .ogg files -> send as voice (round playable bubble)
if audio_path.endswith(".ogg") or audio_path.endswith(".opus"):
if audio_path.endswith((".ogg", ".opus")):
_voice_thread = metadata.get("thread_id") if metadata else None
msg = await self._bot.send_voice(
chat_id=int(chat_id),
@@ -1516,7 +1632,12 @@ class TelegramAdapter(BasePlatformAdapter):
"""
if not self._bot:
return SendResult(success=False, error="Not connected")
from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("[%s] Blocked unsafe image URL (SSRF protection)", self.name)
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
try:
# Telegram can send photos directly from URLs (up to ~5MB)
_photo_thread = metadata.get("thread_id") if metadata else None
@@ -2106,10 +2227,7 @@ class TelegramAdapter(BasePlatformAdapter):
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
if event.text:
if not existing.text:
existing.text = event.text
elif event.text not in existing.text:
existing.text = f"{existing.text}\n\n{event.text}".strip()
existing.text = self._merge_caption(existing.text, event.text)
prior_task = self._pending_photo_batch_tasks.get(batch_key)
if prior_task and not prior_task.done():
@@ -2299,11 +2417,7 @@ class TelegramAdapter(BasePlatformAdapter):
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
if event.text:
if existing.text:
if event.text not in existing.text.split("\n\n"):
existing.text = f"{existing.text}\n\n{event.text}"
else:
existing.text = event.text
existing.text = self._merge_caption(existing.text, event.text)
prior_task = self._media_group_tasks.get(media_group_id)
if prior_task:
@@ -2559,3 +2673,46 @@ class TelegramAdapter(BasePlatformAdapter):
auto_skill=topic_skill,
timestamp=message.date,
)
# ── Message reactions (processing lifecycle) ──────────────────────────
def _reactions_enabled(self) -> bool:
"""Check if message reactions are enabled via config/env."""
return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in ("false", "0", "no")
async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
"""Set a single emoji reaction on a Telegram message."""
if not self._bot:
return False
try:
await self._bot.set_message_reaction(
chat_id=int(chat_id),
message_id=int(message_id),
reaction=emoji,
)
return True
except Exception as e:
logger.debug("[%s] set_message_reaction failed (%s): %s", self.name, emoji, e)
return False
async def on_processing_start(self, event: MessageEvent) -> None:
"""Add an in-progress reaction when message processing begins."""
if not self._reactions_enabled():
return
chat_id = getattr(event.source, "chat_id", None)
message_id = getattr(event, "message_id", None)
if chat_id and message_id:
await self._set_reaction(chat_id, message_id, "\U0001f440")
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
"""Swap the in-progress reaction for a final success/failure reaction.
Unlike Discord (additive reactions), Telegram's set_message_reaction
replaces all existing reactions in one call no remove step needed.
"""
if not self._reactions_enabled():
return
chat_id = getattr(event.source, "chat_id", None)
message_id = getattr(event, "message_id", None)
if chat_id and message_id:
await self._set_reaction(chat_id, message_id, "\u2705" if success else "\u274c")
+41 -9
View File
@@ -76,8 +76,17 @@ class WebhookAdapter(BasePlatformAdapter):
self._routes: Dict[str, dict] = dict(self._static_routes)
self._runner = None
# Delivery info keyed by session chat_id — consumed by send()
# Delivery info keyed by session chat_id.
#
# Read by every send() invocation for the chat_id (status messages
# AND the final response). Cleaned up via TTL on each POST so the
# dict stays bounded — see _prune_delivery_info(). Do NOT pop on
# send(), or interim status messages (e.g. fallback notifications,
# context-pressure warnings) will consume the entry before the
# final response arrives, causing the response to silently fall
# back to the "log" deliver type.
self._delivery_info: Dict[str, dict] = {}
self._delivery_info_created: Dict[str, float] = {}
# Reference to gateway runner for cross-platform delivery (set externally)
self.gateway_runner = None
@@ -160,10 +169,14 @@ class WebhookAdapter(BasePlatformAdapter):
) -> SendResult:
"""Deliver the agent's response to the configured destination.
chat_id is ``webhook:{route}:{delivery_id}`` we pop the delivery
info stored during webhook receipt so it doesn't leak memory.
chat_id is ``webhook:{route}:{delivery_id}``. The delivery info
stored during webhook receipt is read with ``.get()`` (not popped)
so that interim status messages emitted before the final response
fallback-model notifications, context-pressure warnings, etc.
do not consume the entry and silently downgrade the final response
to the ``log`` deliver type. TTL cleanup happens on POST.
"""
delivery = self._delivery_info.pop(chat_id, {})
delivery = self._delivery_info.get(chat_id, {})
deliver_type = delivery.get("deliver", "log")
if deliver_type == "log":
@@ -190,6 +203,23 @@ class WebhookAdapter(BasePlatformAdapter):
success=False, error=f"Unknown deliver type: {deliver_type}"
)
def _prune_delivery_info(self, now: float) -> None:
"""Drop delivery_info entries older than the idempotency TTL.
Mirrors the cleanup pattern used for ``_seen_deliveries``. Called
on each POST so the dict size is bounded by ``rate_limit * TTL``
even if many webhooks fire and never receive a final response.
"""
cutoff = now - self._idempotency_ttl
stale = [
k
for k, t in self._delivery_info_created.items()
if t < cutoff
]
for k in stale:
self._delivery_info.pop(k, None)
self._delivery_info_created.pop(k, None)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": chat_id, "type": "webhook"}
@@ -203,10 +233,8 @@ class WebhookAdapter(BasePlatformAdapter):
def _reload_dynamic_routes(self) -> None:
"""Reload agent-created subscriptions from disk if the file changed."""
from pathlib import Path as _Path
hermes_home = _Path(
os.getenv("HERMES_HOME", str(_Path.home() / ".hermes"))
).expanduser()
from hermes_constants import get_hermes_home
hermes_home = get_hermes_home()
subs_path = hermes_home / _DYNAMIC_ROUTES_FILENAME
if not subs_path.exists():
if self._dynamic_routes:
@@ -384,7 +412,9 @@ class WebhookAdapter(BasePlatformAdapter):
# same route get independent agent runs (not queued/interrupted).
session_chat_id = f"webhook:{route_name}:{delivery_id}"
# Store delivery info for send() — consumed (popped) on delivery
# Store delivery info for send(). Read by every send() invocation
# for this chat_id (interim status messages and the final response),
# so we do NOT pop on send. TTL-based cleanup keeps the dict bounded.
deliver_config = {
"deliver": route_config.get("deliver", "log"),
"deliver_extra": self._render_delivery_extra(
@@ -393,6 +423,8 @@ class WebhookAdapter(BasePlatformAdapter):
"payload": payload,
}
self._delivery_info[session_chat_id] = deliver_config
self._delivery_info_created[session_chat_id] = now
self._prune_delivery_info(now)
# Build source and event
source = self.build_source(
+6 -2
View File
@@ -653,7 +653,7 @@ class WeComAdapter(BasePlatformAdapter):
return ".png"
if data.startswith(b"\xff\xd8\xff"):
return ".jpg"
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
if data.startswith((b"GIF87a", b"GIF89a")):
return ".gif"
if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
return ".webp"
@@ -689,7 +689,7 @@ class WeComAdapter(BasePlatformAdapter):
@staticmethod
def _derive_message_type(body: Dict[str, Any], text: str, media_types: List[str]) -> MessageType:
"""Choose the normalized inbound message type."""
if any(mtype.startswith("application/") or mtype.startswith("text/") for mtype in media_types):
if any(mtype.startswith(("application/", "text/")) for mtype in media_types):
return MessageType.DOCUMENT
if any(mtype.startswith("image/") for mtype in media_types):
return MessageType.TEXT if text else MessageType.PHOTO
@@ -910,6 +910,10 @@ class WeComAdapter(BasePlatformAdapter):
url: str,
max_bytes: int,
) -> Tuple[bytes, Dict[str, str]]:
from tools.url_safety import is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {url[:80]}")
if not HTTPX_AVAILABLE:
raise RuntimeError("httpx is required for WeCom media download")
-1
View File
@@ -27,7 +27,6 @@ _IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
from typing import Dict, Optional, Any
from hermes_cli.config import get_hermes_home
from hermes_constants import get_hermes_dir
logger = logging.getLogger(__name__)
+67 -25
View File
@@ -24,7 +24,6 @@ import signal
import tempfile
import threading
import time
import uuid
from pathlib import Path
from datetime import datetime
from typing import Dict, Optional, Any, List
@@ -378,7 +377,7 @@ def _check_unavailable_skill(command_name: str) -> str | None:
)
# Check optional skills (shipped with repo but not installed)
from hermes_constants import get_hermes_home, get_optional_skills_dir
from hermes_constants import get_optional_skills_dir
repo_root = Path(__file__).resolve().parent.parent
optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
if optional_dir.exists():
@@ -1858,6 +1857,11 @@ class GatewayRunner:
if _quick_key in self._running_agents and _stale_ts:
_stale_age = time.time() - _stale_ts
_stale_agent = self._running_agents.get(_quick_key)
# Never evict the pending sentinel — it was just placed moments
# ago during the async setup phase before the real agent is
# created. Sentinels have no get_activity_summary(), so the
# idle check below would always evaluate to inf >= timeout and
# immediately evict them, racing with the setup path.
_stale_idle = float("inf") # assume idle if we can't check
_stale_detail = ""
if _stale_agent and hasattr(_stale_agent, "get_activity_summary"):
@@ -1876,8 +1880,11 @@ class GatewayRunner:
# cases where the agent object was garbage-collected).
_wall_ttl = max(_raw_stale_timeout * 10, 7200) if _raw_stale_timeout > 0 else float("inf")
_should_evict = (
(_raw_stale_timeout > 0 and _stale_idle >= _raw_stale_timeout)
or _stale_age > _wall_ttl
_stale_agent is not _AGENT_PENDING_SENTINEL
and (
(_raw_stale_timeout > 0 and _stale_idle >= _raw_stale_timeout)
or _stale_age > _wall_ttl
)
)
if _should_evict:
logger.warning(
@@ -1980,10 +1987,7 @@ class GatewayRunner:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
if event.text:
if not existing.text:
existing.text = event.text
elif event.text not in existing.text:
existing.text = f"{existing.text}\n\n{event.text}".strip()
existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)
else:
adapter._pending_messages[_quick_key] = event
else:
@@ -2814,7 +2818,7 @@ class GatewayRunner:
guessed, _ = _mimetypes.guess_type(path)
if guessed:
mtype = guessed
if not (mtype.startswith("application/") or mtype.startswith("text/")):
if not mtype.startswith(("application/", "text/")):
continue
# Extract display filename by stripping the doc_{uuid12}_ prefix
import os as _os
@@ -3338,25 +3342,36 @@ class GatewayRunner:
"""Handle /status command."""
source = event.source
session_entry = self.session_store.get_or_create_session(source)
connected_platforms = [p.value for p in self.adapters.keys()]
# Check if there's an active agent
session_key = session_entry.session_key
is_running = session_key in self._running_agents
title = None
if self._session_db:
try:
title = self._session_db.get_session_title(session_entry.session_id)
except Exception:
title = None
lines = [
"📊 **Hermes Gateway Status**",
"",
f"**Session ID:** `{session_entry.session_id[:12]}...`",
f"**Session ID:** `{session_entry.session_id}`",
]
if title:
lines.append(f"**Title:** {title}")
lines.extend([
f"**Created:** {session_entry.created_at.strftime('%Y-%m-%d %H:%M')}",
f"**Last Activity:** {session_entry.updated_at.strftime('%Y-%m-%d %H:%M')}",
f"**Tokens:** {session_entry.total_tokens:,}",
f"**Agent Running:** {'Yes ⚡' if is_running else 'No'}",
"",
f"**Connected Platforms:** {', '.join(connected_platforms)}",
]
])
return "\n".join(lines)
async def _handle_stop_command(self, event: MessageEvent) -> str:
@@ -3901,7 +3916,7 @@ class GatewayRunner:
return f"🎭 Personality set to **{args}**\n_(takes effect on next message)_"
available = "`none`, " + ", ".join(f"`{n}`" for n in personalities.keys())
available = "`none`, " + ", ".join(f"`{n}`" for n in personalities)
return f"Unknown personality: `{args}`\n\nAvailable: {available}"
async def _handle_retry_command(self, event: MessageEvent) -> str:
@@ -4544,6 +4559,7 @@ class GatewayRunner:
provider_data_collection=pr.get("data_collection"),
session_id=task_id,
platform=platform_key,
user_id=source.user_id,
session_db=self._session_db,
fallback_model=self._fallback_model,
)
@@ -4905,8 +4921,8 @@ class GatewayRunner:
cycle = ["off", "new", "all", "verbose"]
descriptions = {
"off": "⚙️ Tool progress: **OFF** — no tool activity shown.",
"new": "⚙️ Tool progress: **NEW** — shown when tool changes (short previews).",
"all": "⚙️ Tool progress: **ALL** — every tool call shown (short previews).",
"new": "⚙️ Tool progress: **NEW** — shown when tool changes (preview length: `display.tool_preview_length`, default 40).",
"all": "⚙️ Tool progress: **ALL** — every tool call shown (preview length: `display.tool_preview_length`, default 40).",
"verbose": "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments.",
}
@@ -5313,9 +5329,6 @@ class GatewayRunner:
old_servers = set(_servers.keys())
# Read new config before shutting down, so we know what will be added/removed
new_config = _load_mcp_config()
new_server_names = set(new_config.keys())
# Shutdown existing connections
await loop.run_in_executor(None, shutdown_mcp_servers)
@@ -5403,7 +5416,6 @@ class GatewayRunner:
from tools.approval import (
resolve_gateway_approval, has_blocking_approval,
pending_approval_count,
)
if not has_blocking_approval(session_key):
@@ -5431,6 +5443,11 @@ class GatewayRunner:
if not count:
return "No pending command to approve."
# Resume typing indicator — agent is about to continue processing.
_adapter = self.adapters.get(source.platform)
if _adapter:
_adapter.resume_typing_for_chat(source.chat_id)
count_msg = f" ({count} commands)" if count > 1 else ""
logger.info("User approved %d dangerous command(s) via /approve%s", count, scope_msg)
return f"✅ Command{'s' if count > 1 else ''} approved{scope_msg}{count_msg}. The agent is resuming..."
@@ -5463,6 +5480,11 @@ class GatewayRunner:
if not count:
return "No pending command to deny."
# Resume typing indicator — agent continues (with BLOCKED result).
_adapter = self.adapters.get(source.platform)
if _adapter:
_adapter.resume_typing_for_chat(source.chat_id)
count_msg = f" ({count} commands)" if count > 1 else ""
logger.info("User denied %d dangerous command(s) via /deny", count)
return f"❌ Command{'s' if count > 1 else ''} denied{count_msg}."
@@ -6022,6 +6044,11 @@ class GatewayRunner:
if enriched_parts:
prefix = "\n\n".join(enriched_parts)
# Strip the empty-content placeholder from the Discord adapter
# when we successfully transcribed the audio — it's redundant.
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return prefix
if user_text:
return f"{prefix}\n\n{user_text}"
return prefix
@@ -6313,10 +6340,15 @@ class GatewayRunner:
progress_queue.put(msg)
return
# "all" / "new" modes: short preview, always truncated (40 chars)
# "all" / "new" modes: short preview, respects tool_preview_length
# config (defaults to 40 chars when unset to keep gateway messages
# compact — unlike CLI spinners, these persist as permanent messages).
if preview:
if len(preview) > 40:
preview = preview[:37] + "..."
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
_cap = _pl if _pl > 0 else 40
if len(preview) > _cap:
preview = preview[:_cap - 3] + "..."
msg = f"{emoji} {tool_name}: \"{preview}\""
else:
msg = f"{emoji} {tool_name}..."
@@ -6632,6 +6664,7 @@ class GatewayRunner:
provider_data_collection=pr.get("data_collection"),
session_id=session_id,
platform=platform_key,
user_id=source.user_id,
session_db=self._session_db,
fallback_model=self._fallback_model,
)
@@ -6756,6 +6789,15 @@ class GatewayRunner:
UX. Otherwise fall back to a plain text message with
``/approve`` instructions.
"""
# Pause the typing indicator while the agent waits for
# user approval. Critical for Slack's Assistant API where
# assistant_threads_setStatus disables the compose box — the
# user literally cannot type /approve while "is thinking..."
# is active. The approval message send auto-clears the Slack
# status; pausing prevents _keep_typing from re-setting it.
# Typing resumes in _handle_approve_command/_handle_deny_command.
_status_adapter.pause_typing_for_chat(_status_chat_id)
cmd = approval_data.get("command", "")
desc = approval_data.get("description", "dangerous command")
+1 -1
View File
@@ -128,7 +128,7 @@ class GatewayStreamConsumer:
got_done
or got_segment_break
or (elapsed >= self.cfg.edit_interval
and len(self._accumulated) > 0)
and self._accumulated)
or len(self._accumulated) >= self.cfg.buffer_threshold
)
+71 -22
View File
@@ -37,7 +37,7 @@ from typing import Any, Dict, List, Optional
import httpx
import yaml
from hermes_cli.config import get_hermes_home, get_config_path
from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
from hermes_constants import OPENROUTER_BASE_URL
logger = logging.getLogger(__name__)
@@ -2214,14 +2214,7 @@ def _update_config_for_provider(
config_path = get_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
config: Dict[str, Any] = {}
if config_path.exists():
try:
loaded = yaml.safe_load(config_path.read_text()) or {}
if isinstance(loaded, dict):
config = loaded
except Exception:
config = {}
config = read_raw_config()
current_model = config.get("model")
if isinstance(current_model, dict):
@@ -2258,12 +2251,8 @@ def _reset_config_provider() -> Path:
if not config_path.exists():
return config_path
try:
config = yaml.safe_load(config_path.read_text()) or {}
except Exception:
return config_path
if not isinstance(config, dict):
config = read_raw_config()
if not config:
return config_path
model = config.get("model")
@@ -2279,14 +2268,21 @@ def _prompt_model_selection(
model_ids: List[str],
current_model: str = "",
pricing: Optional[Dict[str, Dict[str, str]]] = None,
unavailable_models: Optional[List[str]] = None,
portal_url: str = "",
) -> Optional[str]:
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
If *pricing* is provided (``{model_id: {prompt, completion}}``), a compact
price indicator is shown next to each model in aligned columns.
If *unavailable_models* is provided, those models are shown grayed out
and unselectable, with an upgrade link to *portal_url*.
"""
from hermes_cli.models import _format_price_per_mtok
_unavailable = unavailable_models or []
# Reorder: current model first, then the rest (deduplicated)
ordered = []
if current_model and current_model in model_ids:
@@ -2295,9 +2291,12 @@ def _prompt_model_selection(
if mid not in ordered:
ordered.append(mid)
# All models for column-width computation (selectable + unavailable)
all_models = list(ordered) + list(_unavailable)
# Column-aligned labels when pricing is available
has_pricing = bool(pricing and any(pricing.get(m) for m in ordered))
name_col = max((len(m) for m in ordered), default=0) + 2 if has_pricing else 0
has_pricing = bool(pricing and any(pricing.get(m) for m in all_models))
name_col = max((len(m) for m in all_models), default=0) + 2 if has_pricing else 0
# Pre-compute formatted prices and dynamic column widths
_price_cache: dict[str, tuple[str, str, str]] = {}
@@ -2305,7 +2304,7 @@ def _prompt_model_selection(
cache_col = 0 # only set if any model has cache pricing
has_cache = False
if has_pricing:
for mid in ordered:
for mid in all_models:
p = pricing.get(mid) # type: ignore[union-attr]
if p:
inp = _format_price_per_mtok(p.get("prompt", ""))
@@ -2350,12 +2349,35 @@ def _prompt_model_selection(
header += f" {'Cache':>{cache_col}}"
menu_title += header + " /Mtok"
# ANSI escape for dim text
_DIM = "\033[2m"
_RESET = "\033[0m"
# Try arrow-key menu first, fall back to number input
try:
from simple_term_menu import TerminalMenu
choices = [f" {_label(mid)}" for mid in ordered]
choices.append(" Enter custom model name")
choices.append(" Skip (keep current)")
# Print the unavailable block BEFORE the menu via regular print().
# simple_term_menu pads title lines to terminal width (causes wrapping),
# so we keep the title minimal and use stdout for the static block.
# clear_screen=False means our printed output stays visible above.
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
if _unavailable:
print(menu_title)
print()
for mid in _unavailable:
print(f"{_DIM} {_label(mid)}{_RESET}")
print()
print(f"{_DIM} ── Upgrade at {_upgrade_url} for paid models ──{_RESET}")
print()
effective_title = "Available free models:"
else:
effective_title = menu_title
menu = TerminalMenu(
choices,
cursor_index=default_idx,
@@ -2364,7 +2386,7 @@ def _prompt_model_selection(
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
title=menu_title,
title=effective_title,
)
idx = menu.show()
if idx is None:
@@ -2387,6 +2409,13 @@ def _prompt_model_selection(
n = len(ordered)
print(f" {n + 1:>{num_width}}. Enter custom model name")
print(f" {n + 2:>{num_width}}. Skip (keep current)")
if _unavailable:
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print()
print(f" {_DIM}── Unavailable models (requires paid tier — upgrade at {_upgrade_url}) ──{_RESET}")
for mid in _unavailable:
print(f" {'':>{num_width}} {_DIM}{_label(mid)}{_RESET}")
print()
while True:
@@ -2799,7 +2828,6 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
)
inference_base_url = auth_state["inference_base_url"]
verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
with _auth_store_lock():
auth_store = _load_auth_store()
@@ -2821,16 +2849,37 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
code="invalid_token",
)
from hermes_cli.models import _PROVIDER_MODELS
from hermes_cli.models import (
_PROVIDER_MODELS, get_pricing_for_provider, filter_nous_free_models,
check_nous_free_tier, partition_nous_models_by_tier,
)
model_ids = _PROVIDER_MODELS.get("nous", [])
print()
unavailable_models: list = []
if model_ids:
pricing = get_pricing_for_provider("nous")
model_ids = filter_nous_free_models(model_ids, pricing)
free_tier = check_nous_free_tier()
if free_tier:
model_ids, unavailable_models = partition_nous_models_by_tier(
model_ids, pricing, free_tier=True,
)
_portal = auth_state.get("portal_base_url", "")
if model_ids:
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
selected_model = _prompt_model_selection(model_ids)
selected_model = _prompt_model_selection(
model_ids, pricing=pricing,
unavailable_models=unavailable_models,
portal_url=_portal,
)
if selected_model:
_save_model_choice(selected_model)
print(f"Default model set to: {selected_model}")
elif unavailable_models:
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print("No free models currently available.")
print(f"Upgrade at {_url} to access paid models.")
else:
print("No curated models available for Nous Portal.")
except Exception as exc:
-1
View File
@@ -18,7 +18,6 @@ from agent.credential_pool import (
STRATEGY_ROUND_ROBIN,
STRATEGY_RANDOM,
STRATEGY_LEAST_USED,
SUPPORTED_POOL_STRATEGIES,
PooledCredential,
_exhausted_until,
_normalize_custom_pool_name,
+74 -1
View File
@@ -190,6 +190,79 @@ def check_for_updates() -> Optional[int]:
return behind
def _resolve_repo_dir() -> Optional[Path]:
"""Return the active Hermes git checkout, or None if this isn't a git install."""
hermes_home = get_hermes_home()
repo_dir = hermes_home / "hermes-agent"
if not (repo_dir / ".git").exists():
repo_dir = Path(__file__).parent.parent.resolve()
return repo_dir if (repo_dir / ".git").exists() else None
def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]:
"""Resolve a git revision to an 8-character short hash."""
try:
result = subprocess.run(
["git", "rev-parse", "--short=8", rev],
capture_output=True,
text=True,
timeout=5,
cwd=str(repo_dir),
)
except Exception:
return None
if result.returncode != 0:
return None
value = (result.stdout or "").strip()
return value or None
def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
"""Return upstream/local git hashes for the startup banner."""
repo_dir = repo_dir or _resolve_repo_dir()
if repo_dir is None:
return None
upstream = _git_short_hash(repo_dir, "origin/main")
local = _git_short_hash(repo_dir, "HEAD")
if not upstream or not local:
return None
ahead = 0
try:
result = subprocess.run(
["git", "rev-list", "--count", "origin/main..HEAD"],
capture_output=True,
text=True,
timeout=5,
cwd=str(repo_dir),
)
if result.returncode == 0:
ahead = int((result.stdout or "0").strip() or "0")
except Exception:
ahead = 0
return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)}
def format_banner_version_label() -> str:
"""Return the version label shown in the startup banner title."""
base = f"Hermes Agent v{VERSION} ({RELEASE_DATE})"
state = get_git_banner_state()
if not state:
return base
upstream = state["upstream"]
local = state["local"]
ahead = int(state.get("ahead") or 0)
if ahead <= 0 or upstream == local:
return f"{base} · upstream {upstream}"
carried_word = "commit" if ahead == 1 else "commits"
return f"{base} · upstream {upstream} · local {local} (+{ahead} carried {carried_word})"
# =========================================================================
# Non-blocking update check
# =========================================================================
@@ -449,7 +522,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
border_color = _skin_color("banner_border", "#CD7F32")
outer_panel = Panel(
layout_table,
title=f"[bold {title_color}]{agent_name} v{VERSION} ({RELEASE_DATE})[/]",
title=f"[bold {title_color}]{format_banner_version_label()}[/]",
border_style=border_color,
padding=(0, 2),
)
+1 -42
View File
@@ -25,7 +25,7 @@ def clarify_callback(cli, question, choices):
timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120)
response_queue = queue.Queue()
is_open_ended = not choices or len(choices) == 0
is_open_ended = not choices
cli._clarify_state = {
"question": question,
@@ -63,47 +63,6 @@ def clarify_callback(cli, question, choices):
)
def sudo_password_callback(cli) -> str:
"""Prompt for sudo password through the TUI.
Sets up a password input area and blocks until the user responds.
"""
timeout = 45
response_queue = queue.Queue()
cli._sudo_state = {"response_queue": response_queue}
cli._sudo_deadline = _time.monotonic() + timeout
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
result = response_queue.get(timeout=1)
cli._sudo_state = None
cli._sudo_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
if result:
cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}")
else:
cprint(f"\n{_DIM} ⏭ Skipped{_RST}")
return result
except queue.Empty:
remaining = cli._sudo_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._sudo_state = None
cli._sudo_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
return ""
def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
"""Prompt for a secret value through the TUI (e.g. API keys for skills).
-2
View File
@@ -10,7 +10,6 @@ Usage:
import importlib.util
import logging
import shutil
import sys
from datetime import datetime
from pathlib import Path
@@ -24,7 +23,6 @@ from hermes_cli.setup import (
print_info,
print_success,
print_error,
print_warning,
prompt_yes_no,
)
+108 -22
View File
@@ -1,4 +1,4 @@
"""Clipboard image extraction for macOS, Linux, and WSL2.
"""Clipboard image extraction for macOS, Windows, Linux, and WSL2.
Provides a single function `save_clipboard_image(dest)` that checks the
system clipboard for image data, saves it to *dest* as PNG, and returns
@@ -6,9 +6,10 @@ True on success. No external Python dependencies — uses only OS-level
CLI tools that ship with the platform (or are commonly installed).
Platform support:
macOS osascript (always available), pngpaste (if installed)
WSL2 powershell.exe via .NET System.Windows.Forms.Clipboard
Linux wl-paste (Wayland), xclip (X11)
macOS osascript (always available), pngpaste (if installed)
Windows PowerShell via .NET System.Windows.Forms.Clipboard
WSL2 powershell.exe via .NET System.Windows.Forms.Clipboard
Linux wl-paste (Wayland), xclip (X11)
"""
import base64
@@ -32,6 +33,8 @@ def save_clipboard_image(dest: Path) -> bool:
dest.parent.mkdir(parents=True, exist_ok=True)
if sys.platform == "darwin":
return _macos_save(dest)
if sys.platform == "win32":
return _windows_save(dest)
return _linux_save(dest)
@@ -42,6 +45,8 @@ def has_clipboard_image() -> bool:
"""
if sys.platform == "darwin":
return _macos_has_image()
if sys.platform == "win32":
return _windows_has_image()
if _is_wsl():
return _wsl_has_image()
if os.environ.get("WAYLAND_DISPLAY"):
@@ -112,6 +117,104 @@ def _macos_osascript(dest: Path) -> bool:
return False
# ── Shared PowerShell scripts (native Windows + WSL2) ─────────────────────
# .NET System.Windows.Forms.Clipboard — used by both native Windows (powershell)
# and WSL2 (powershell.exe) paths.
_PS_CHECK_IMAGE = (
"Add-Type -AssemblyName System.Windows.Forms;"
"[System.Windows.Forms.Clipboard]::ContainsImage()"
)
_PS_EXTRACT_IMAGE = (
"Add-Type -AssemblyName System.Windows.Forms;"
"Add-Type -AssemblyName System.Drawing;"
"$img = [System.Windows.Forms.Clipboard]::GetImage();"
"if ($null -eq $img) { exit 1 }"
"$ms = New-Object System.IO.MemoryStream;"
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);"
"[System.Convert]::ToBase64String($ms.ToArray())"
)
# ── Native Windows ────────────────────────────────────────────────────────
# Native Windows uses ``powershell`` (Windows PowerShell 5.1, always present)
# or ``pwsh`` (PowerShell 7+, optional). Discovery is cached per-process.
def _find_powershell() -> str | None:
"""Return the first available PowerShell executable, or None."""
for name in ("powershell", "pwsh"):
try:
r = subprocess.run(
[name, "-NoProfile", "-NonInteractive", "-Command", "echo ok"],
capture_output=True, text=True, timeout=5,
)
if r.returncode == 0 and "ok" in r.stdout:
return name
except FileNotFoundError:
continue
except Exception:
continue
return None
# Cache the resolved PowerShell executable (checked once per process)
_ps_exe: str | None | bool = False # False = not yet checked
def _get_ps_exe() -> str | None:
global _ps_exe
if _ps_exe is False:
_ps_exe = _find_powershell()
return _ps_exe
def _windows_has_image() -> bool:
"""Check if the Windows clipboard contains an image."""
ps = _get_ps_exe()
if ps is None:
return False
try:
r = subprocess.run(
[ps, "-NoProfile", "-NonInteractive", "-Command", _PS_CHECK_IMAGE],
capture_output=True, text=True, timeout=5,
)
return r.returncode == 0 and "True" in r.stdout
except Exception as e:
logger.debug("Windows clipboard image check failed: %s", e)
return False
def _windows_save(dest: Path) -> bool:
"""Extract clipboard image on native Windows via PowerShell → base64 PNG."""
ps = _get_ps_exe()
if ps is None:
logger.debug("No PowerShell found — Windows clipboard image paste unavailable")
return False
try:
r = subprocess.run(
[ps, "-NoProfile", "-NonInteractive", "-Command", _PS_EXTRACT_IMAGE],
capture_output=True, text=True, timeout=15,
)
if r.returncode != 0:
return False
b64_data = r.stdout.strip()
if not b64_data:
return False
png_bytes = base64.b64decode(b64_data)
dest.write_bytes(png_bytes)
return dest.exists() and dest.stat().st_size > 0
except Exception as e:
logger.debug("Windows clipboard image extraction failed: %s", e)
dest.unlink(missing_ok=True)
return False
# ── Linux ────────────────────────────────────────────────────────────────
def _is_wsl() -> bool:
@@ -142,24 +245,7 @@ def _linux_save(dest: Path) -> bool:
# ── WSL2 (powershell.exe) ────────────────────────────────────────────────
# PowerShell script: get clipboard image as base64-encoded PNG on stdout.
# Using .NET System.Windows.Forms.Clipboard — always available on Windows.
_PS_CHECK_IMAGE = (
"Add-Type -AssemblyName System.Windows.Forms;"
"[System.Windows.Forms.Clipboard]::ContainsImage()"
)
_PS_EXTRACT_IMAGE = (
"Add-Type -AssemblyName System.Windows.Forms;"
"Add-Type -AssemblyName System.Drawing;"
"$img = [System.Windows.Forms.Clipboard]::GetImage();"
"if ($null -eq $img) { exit 1 }"
"$ms = New-Object System.IO.MemoryStream;"
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);"
"[System.Convert]::ToBase64String($ms.ToArray())"
)
# Reuses _PS_CHECK_IMAGE / _PS_EXTRACT_IMAGE defined above.
def _wsl_has_image() -> bool:
"""Check if Windows clipboard has an image (via powershell.exe)."""
+2 -10
View File
@@ -293,16 +293,8 @@ def _resolve_config_gates() -> set[str]:
if not gated:
return set()
try:
import yaml
config_path = os.path.join(
os.getenv("HERMES_HOME", os.path.expanduser("~/.hermes")),
"config.yaml",
)
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
else:
cfg = {}
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
except Exception:
return set()
result: set[str] = set()
+20 -1
View File
@@ -416,6 +416,7 @@ DEFAULT_CONFIG = {
"provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API)
"local": {
"model": "base", # tiny, base, small, medium, large-v3
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
},
"openai": {
"model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
@@ -1881,6 +1882,24 @@ def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
def read_raw_config() -> Dict[str, Any]:
"""Read ~/.hermes/config.yaml as-is, without merging defaults or migrating.
Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't
be parsed. Use this for lightweight config reads where you just need a
single value and don't want the overhead of ``load_config()``'s deep-merge
+ migration pipeline.
"""
try:
config_path = get_config_path()
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception:
pass
return {}
def load_config() -> Dict[str, Any]:
"""Load configuration from ~/.hermes/config.yaml."""
import copy
@@ -2520,7 +2539,7 @@ def set_config_value(key: str, value: str):
'TINKER_API_KEY',
]
if key.upper() in api_keys or key.upper().endswith('_API_KEY') or key.upper().endswith('_TOKEN') or key.upper().startswith('TERMINAL_SSH'):
if key.upper() in api_keys or key.upper().endswith(('_API_KEY', '_TOKEN')) or key.upper().startswith('TERMINAL_SSH'):
save_env_value(key.upper(), value)
print(f"✓ Set {key} in {get_env_path()}")
return
+2 -2
View File
@@ -920,8 +920,8 @@ def run_doctor(args):
pass
except ImportError:
pass
except Exception as _e:
logger.debug("Profile health check failed: %s", _e)
except Exception:
pass
# =========================================================================
# Summary
+51 -9
View File
@@ -267,6 +267,34 @@ def _profile_suffix() -> str:
return hashlib.sha256(str(home).encode()).hexdigest()[:8]
def _profile_arg(hermes_home: str | None = None) -> str:
"""Return ``--profile <name>`` only when HERMES_HOME is a named profile.
For ``~/.hermes/profiles/<name>``, returns ``"--profile <name>"``.
For the default profile or hash-based custom paths, returns the empty string.
Args:
hermes_home: Optional explicit HERMES_HOME path. Defaults to the current
``get_hermes_home()`` value. Should be passed when generating a
service definition for a different user (e.g. system service).
"""
import re
from pathlib import Path as _Path
home = Path(hermes_home or str(get_hermes_home())).resolve()
default = (_Path.home() / ".hermes").resolve()
if home == default:
return ""
profiles_root = (default / "profiles").resolve()
try:
rel = home.relative_to(profiles_root)
parts = rel.parts
if len(parts) == 1 and re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", parts[0]):
return f"--profile {parts[0]}"
except ValueError:
pass
return ""
def get_service_name() -> str:
"""Derive a systemd service name scoped to this HERMES_HOME.
@@ -626,6 +654,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
if system:
username, group_name, home_dir = _system_service_identity(run_as_user)
hermes_home = _hermes_home_for_target_user(home_dir)
profile_arg = _profile_arg(hermes_home)
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
path_entries.extend(common_bin_paths)
sane_path = ":".join(path_entries)
@@ -640,7 +669,7 @@ StartLimitBurst=5
Type=simple
User={username}
Group={group_name}
ExecStart={python_path} -m hermes_cli.main gateway run --replace
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
WorkingDirectory={working_dir}
Environment="HOME={home_dir}"
Environment="USER={username}"
@@ -661,6 +690,7 @@ WantedBy=multi-user.target
"""
hermes_home = str(get_hermes_home().resolve())
profile_arg = _profile_arg(hermes_home)
path_entries.extend(_build_user_local_paths(Path.home(), path_entries))
path_entries.extend(common_bin_paths)
sane_path = ":".join(path_entries)
@@ -672,7 +702,7 @@ StartLimitBurst=5
[Service]
Type=simple
ExecStart={python_path} -m hermes_cli.main gateway run --replace
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
WorkingDirectory={working_dir}
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
@@ -965,6 +995,7 @@ def generate_launchd_plist() -> str:
log_dir = get_hermes_home() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
label = get_launchd_label()
profile_arg = _profile_arg(hermes_home)
# Build a sane PATH for the launchd plist. launchd provides only a
# minimal default (/usr/bin:/bin:/usr/sbin:/sbin) which misses Homebrew,
# nvm, cargo, etc. We prepend venv/bin and node_modules/.bin (matching
@@ -986,21 +1017,32 @@ def generate_launchd_plist() -> str:
dict.fromkeys(priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p])
)
# Build ProgramArguments array, including --profile when using a named profile
prog_args = [
f"<string>{python_path}</string>",
"<string>-m</string>",
"<string>hermes_cli.main</string>",
]
if profile_arg:
for part in profile_arg.split():
prog_args.append(f"<string>{part}</string>")
prog_args.extend([
"<string>gateway</string>",
"<string>run</string>",
"<string>--replace</string>",
])
prog_args_xml = "\n ".join(prog_args)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{python_path}</string>
<string>-m</string>
<string>hermes_cli.main</string>
<string>gateway</string>
<string>run</string>
<string>--replace</string>
{prog_args_xml}
</array>
<key>WorkingDirectory</key>
-1
View File
@@ -15,7 +15,6 @@ Usage examples::
hermes logs --since 30m -f # follow, starting 30 min ago
"""
import os
import re
import sys
import time
+60 -26
View File
@@ -1154,7 +1154,7 @@ def _model_flow_nous(config, current_model="", args=None):
from hermes_cli.auth import (
get_provider_auth_state, _prompt_model_selection, _save_model_choice,
_update_config_for_provider, resolve_nous_runtime_credentials,
fetch_nous_models, AuthError, format_auth_error,
AuthError, format_auth_error,
_login_nous, PROVIDER_REGISTRY,
)
from hermes_cli.config import get_env_value, save_config, save_env_value
@@ -1195,14 +1195,15 @@ def _model_flow_nous(config, current_model="", args=None):
# Already logged in — use curated model list (same as OpenRouter defaults).
# The live /models endpoint returns hundreds of models; the curated list
# shows only agentic models users recognize from OpenRouter.
from hermes_cli.models import _PROVIDER_MODELS, get_pricing_for_provider
from hermes_cli.models import (
_PROVIDER_MODELS, get_pricing_for_provider, filter_nous_free_models,
check_nous_free_tier, partition_nous_models_by_tier,
)
model_ids = _PROVIDER_MODELS.get("nous", [])
if not model_ids:
print("No curated models available for Nous Portal.")
return
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
# Verify credentials are still valid (catches expired sessions early)
try:
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60)
@@ -1228,7 +1229,44 @@ def _model_flow_nous(config, current_model="", args=None):
# Fetch live pricing (non-blocking — returns empty dict on failure)
pricing = get_pricing_for_provider("nous")
selected = _prompt_model_selection(model_ids, current_model=current_model, pricing=pricing)
# Check if user is on free tier
free_tier = check_nous_free_tier()
# For both tiers: apply the allowlist filter first (removes non-allowlisted
# free models and allowlist models that aren't actually free).
# Then for free users: partition remaining models into selectable/unavailable.
model_ids = filter_nous_free_models(model_ids, pricing)
unavailable_models: list[str] = []
if free_tier:
model_ids, unavailable_models = partition_nous_models_by_tier(model_ids, pricing, free_tier=True)
if not model_ids and not unavailable_models:
print("No models available for Nous Portal after filtering.")
return
# Resolve portal URL for upgrade links (may differ on staging)
_nous_portal_url = ""
try:
_nous_state = get_provider_auth_state("nous")
if _nous_state:
_nous_portal_url = _nous_state.get("portal_base_url", "")
except Exception:
pass
if free_tier and not model_ids:
print("No free models currently available.")
if unavailable_models:
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL
_url = (_nous_portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print(f"Upgrade at {_url} to access paid models.")
return
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
selected = _prompt_model_selection(
model_ids, current_model=current_model, pricing=pricing,
unavailable_models=unavailable_models, portal_url=_nous_portal_url,
)
if selected:
_save_model_choice(selected)
# Reactivate Nous as the provider and update config
@@ -1276,7 +1314,6 @@ def _model_flow_openai_codex(config, current_model=""):
PROVIDER_REGISTRY, DEFAULT_CODEX_BASE_URL,
)
from hermes_cli.codex_models import get_codex_model_ids
from hermes_cli.config import get_env_value, save_env_value
import argparse
status = get_codex_auth_status()
@@ -1329,7 +1366,7 @@ def _model_flow_custom(config):
so it appears in the provider menu on subsequent runs.
"""
from hermes_cli.auth import _save_model_choice, deactivate_provider
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
from hermes_cli.config import get_env_value, load_config, save_config
current_url = get_env_value("OPENAI_BASE_URL") or ""
current_key = get_env_value("OPENAI_API_KEY") or ""
@@ -1591,7 +1628,7 @@ def _model_flow_named_custom(config, provider_info):
Otherwise probes the endpoint's /models API to let the user pick one.
"""
from hermes_cli.auth import _save_model_choice, deactivate_provider
from hermes_cli.config import save_env_value, load_config, save_config
from hermes_cli.config import load_config, save_config
from hermes_cli.models import fetch_api_models
name = provider_info["name"]
@@ -1801,7 +1838,7 @@ def _model_flow_copilot(config, current_model=""):
deactivate_provider,
resolve_api_key_provider_credentials,
)
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
from hermes_cli.config import save_env_value, load_config, save_config
from hermes_cli.models import (
fetch_api_models,
fetch_github_model_catalog,
@@ -2392,8 +2429,6 @@ def _model_flow_anthropic(config, current_model=""):
)
from hermes_cli.models import _PROVIDER_MODELS
pconfig = PROVIDER_REGISTRY["anthropic"]
# Check ALL credential sources
existing_key = (
get_env_value("ANTHROPIC_TOKEN")
@@ -3662,7 +3697,7 @@ def cmd_update(args):
try:
from hermes_cli.gateway import (
is_macos, is_linux, _ensure_user_systemd_env,
get_systemd_linger_status, find_gateway_pids,
find_gateway_pids,
_get_service_pids,
)
import signal as _signal
@@ -3818,7 +3853,7 @@ def cmd_profile(args):
"""Profile management — create, delete, list, switch, alias."""
from hermes_cli.profiles import (
list_profiles, create_profile, delete_profile, seed_profile_skills,
get_active_profile, set_active_profile, get_active_profile_name,
set_active_profile, get_active_profile_name,
check_alias_collision, create_wrapper_script, remove_wrapper_script,
_is_wrapper_dir_in_path, _get_wrapper_dir,
)
@@ -3946,7 +3981,6 @@ def cmd_profile(args):
print(f" {name} chat Start chatting")
print(f" {name} gateway start Start the messaging gateway")
if clone or clone_all:
from hermes_constants import get_hermes_home
profile_dir_display = f"~/.hermes/profiles/{name}"
print(f"\n Edit {profile_dir_display}/.env for different API keys")
print(f" Edit {profile_dir_display}/SOUL.md for different personality")
@@ -4369,7 +4403,7 @@ For more help on a command:
gateway_uninstall.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service")
# gateway setup
gateway_setup = gateway_subparsers.add_parser("setup", help="Configure messaging platforms")
gateway_subparsers.add_parser("setup", help="Configure messaging platforms")
gateway_parser.set_defaults(func=cmd_gateway)
@@ -4644,10 +4678,10 @@ For more help on a command:
config_subparsers = config_parser.add_subparsers(dest="config_command")
# config show (default)
config_show = config_subparsers.add_parser("show", help="Show current configuration")
config_subparsers.add_parser("show", help="Show current configuration")
# config edit
config_edit = config_subparsers.add_parser("edit", help="Open config file in editor")
config_subparsers.add_parser("edit", help="Open config file in editor")
# config set
config_set = config_subparsers.add_parser("set", help="Set a configuration value")
@@ -4655,16 +4689,16 @@ For more help on a command:
config_set.add_argument("value", nargs="?", help="Value to set")
# config path
config_path = config_subparsers.add_parser("path", help="Print config file path")
config_subparsers.add_parser("path", help="Print config file path")
# config env-path
config_env = config_subparsers.add_parser("env-path", help="Print .env file path")
config_subparsers.add_parser("env-path", help="Print .env file path")
# config check
config_check = config_subparsers.add_parser("check", help="Check for missing/outdated config")
config_subparsers.add_parser("check", help="Check for missing/outdated config")
# config migrate
config_migrate = config_subparsers.add_parser("migrate", help="Update config with new options")
config_subparsers.add_parser("migrate", help="Update config with new options")
config_parser.set_defaults(func=cmd_config)
@@ -4678,7 +4712,7 @@ For more help on a command:
)
pairing_sub = pairing_parser.add_subparsers(dest="pairing_action")
pairing_list_parser = pairing_sub.add_parser("list", help="Show pending + approved users")
pairing_sub.add_parser("list", help="Show pending + approved users")
pairing_approve_parser = pairing_sub.add_parser("approve", help="Approve a pairing code")
pairing_approve_parser.add_argument("platform", help="Platform name (telegram, discord, slack, whatsapp)")
@@ -4688,7 +4722,7 @@ For more help on a command:
pairing_revoke_parser.add_argument("platform", help="Platform name")
pairing_revoke_parser.add_argument("user_id", help="User ID to revoke")
pairing_clear_parser = pairing_sub.add_parser("clear-pending", help="Clear all pending codes")
pairing_sub.add_parser("clear-pending", help="Clear all pending codes")
def cmd_pairing(args):
from hermes_cli.pairing import pairing_command
@@ -4864,7 +4898,7 @@ For more help on a command:
memory_sub = memory_parser.add_subparsers(dest="memory_command")
memory_sub.add_parser("setup", help="Interactive provider selection and configuration")
memory_sub.add_parser("status", help="Show current memory provider config")
memory_off_p = memory_sub.add_parser("off", help="Disable external provider (built-in only)")
memory_sub.add_parser("off", help="Disable external provider (built-in only)")
def cmd_memory(args):
sub = getattr(args, "memory_command", None)
@@ -5028,7 +5062,7 @@ For more help on a command:
sessions_prune.add_argument("--source", help="Only prune sessions from this source")
sessions_prune.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
sessions_stats = sessions_subparsers.add_parser("stats", help="Show session store statistics")
sessions_subparsers.add_parser("stats", help="Show session store statistics")
sessions_rename = sessions_subparsers.add_parser("rename", help="Set or change a session's title")
sessions_rename.add_argument("session_id", help="Session ID to rename")
@@ -5388,7 +5422,7 @@ For more help on a command:
)
profile_subparsers = profile_parser.add_subparsers(dest="profile_action")
profile_list = profile_subparsers.add_parser("list", help="List all profiles")
profile_subparsers.add_parser("list", help="List all profiles")
profile_use = profile_subparsers.add_parser("use", help="Set sticky default profile")
profile_use.add_argument("profile_name", help="Profile name (or 'default')")
+6 -4
View File
@@ -12,6 +12,8 @@ import os
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
# ---------------------------------------------------------------------------
# Curses-based interactive picker (same pattern as hermes tools)
@@ -275,7 +277,7 @@ def cmd_setup_provider(provider_name: str) -> None:
config["memory"] = {}
if hasattr(provider, "post_setup"):
hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))))
hermes_home = str(get_hermes_home())
provider.post_setup(hermes_home, config)
return
@@ -326,7 +328,7 @@ def cmd_setup(args) -> None:
# If the provider has a post_setup hook, delegate entirely to it.
# The hook handles its own config, connection test, and activation.
if hasattr(provider, "post_setup"):
hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))))
hermes_home = str(get_hermes_home())
provider.post_setup(hermes_home, config)
return
@@ -336,7 +338,7 @@ def cmd_setup(args) -> None:
if not isinstance(provider_config, dict):
provider_config = {}
env_path = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))) / ".env"
env_path = get_hermes_home() / ".env"
env_writes = {}
if schema:
@@ -400,7 +402,7 @@ def cmd_setup(args) -> None:
save_config(config)
# Write non-secret config to provider's native location
hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))))
hermes_home = str(get_hermes_home())
if provider_config and hasattr(provider, "save_config"):
try:
provider.save_config(provider_config, hermes_home)
+1 -7
View File
@@ -21,22 +21,16 @@ OpenRouter variant suffixes (``:free``, ``:extended``, ``:fast``).
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import List, NamedTuple, Optional
from hermes_cli.providers import (
ALIASES,
LABELS,
TRANSPORT_TO_API_MODE,
determine_api_mode,
get_label,
get_provider,
is_aggregator,
normalize_provider,
resolve_provider_full,
)
from hermes_cli.model_normalize import (
detect_vendor,
normalize_model_for_provider,
)
from agent.models_dev import (
+198 -6
View File
@@ -44,7 +44,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("stepfun/step-3.5-flash", ""),
("minimax/minimax-m2.7", ""),
("minimax/minimax-m2.5", ""),
("z-ai/glm-5", ""),
("z-ai/glm-5.1", ""),
("z-ai/glm-5-turbo", ""),
("moonshotai/kimi-k2.5", ""),
("x-ai/grok-4.20-beta", ""),
@@ -75,7 +75,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"stepfun/step-3.5-flash",
"minimax/minimax-m2.7",
"minimax/minimax-m2.5",
"z-ai/glm-5",
"z-ai/glm-5.1",
"z-ai/glm-5-turbo",
"moonshotai/kimi-k2.5",
"x-ai/grok-4.20-beta",
@@ -265,6 +265,202 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
],
}
# ---------------------------------------------------------------------------
# Nous Portal free-model filtering
# ---------------------------------------------------------------------------
# Models that are ALLOWED to appear when priced as free on Nous Portal.
# Any other free model is hidden — prevents promotional/temporary free models
# from cluttering the selection when users are paying subscribers.
# Models in this list are ALSO filtered out if they are NOT free (i.e. they
# should only appear in the menu when they are genuinely free).
_NOUS_ALLOWED_FREE_MODELS: frozenset[str] = frozenset({
"xiaomi/mimo-v2-pro",
"xiaomi/mimo-v2-omni",
})
def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool:
"""Return True if *model_id* has zero-cost prompt AND completion pricing."""
p = pricing.get(model_id)
if not p:
return False
try:
return float(p.get("prompt", "1")) == 0 and float(p.get("completion", "1")) == 0
except (TypeError, ValueError):
return False
def filter_nous_free_models(
model_ids: list[str],
pricing: dict[str, dict[str, str]],
) -> list[str]:
"""Filter the Nous Portal model list according to free-model policy.
Rules:
Paid models that are NOT in the allowlist keep (normal case).
Free models that are NOT in the allowlist drop.
Allowlist models that ARE free keep.
Allowlist models that are NOT free drop.
"""
if not pricing:
return model_ids # no pricing data — can't filter, show everything
result: list[str] = []
for mid in model_ids:
free = _is_model_free(mid, pricing)
if mid in _NOUS_ALLOWED_FREE_MODELS:
# Allowlist model: only show when it's actually free
if free:
result.append(mid)
else:
# Regular model: keep only when it's NOT free
if not free:
result.append(mid)
return result
# ---------------------------------------------------------------------------
# Nous Portal account tier detection
# ---------------------------------------------------------------------------
def fetch_nous_account_tier(access_token: str, portal_base_url: str = "") -> dict[str, Any]:
"""Fetch the user's Nous Portal account/subscription info.
Calls ``<portal>/api/oauth/account`` with the OAuth access token.
Returns the parsed JSON dict on success, e.g.::
{
"subscription": {
"plan": "Plus",
"tier": 2,
"monthly_charge": 20,
"credits_remaining": 1686.60,
...
},
...
}
Returns an empty dict on any failure (network, auth, parse).
"""
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
url = f"{base}/api/oauth/account"
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
}
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=8) as resp:
return json.loads(resp.read().decode())
except Exception:
return {}
def is_nous_free_tier(account_info: dict[str, Any]) -> bool:
"""Return True if the account info indicates a free (unpaid) tier.
Checks ``subscription.monthly_charge == 0``. Returns False when
the field is missing or unparseable (assumes paid don't block users).
"""
sub = account_info.get("subscription")
if not isinstance(sub, dict):
return False
charge = sub.get("monthly_charge")
if charge is None:
return False
try:
return float(charge) == 0
except (TypeError, ValueError):
return False
def partition_nous_models_by_tier(
model_ids: list[str],
pricing: dict[str, dict[str, str]],
free_tier: bool,
) -> tuple[list[str], list[str]]:
"""Split Nous models into (selectable, unavailable) based on user tier.
For paid-tier users: all models are selectable, none unavailable
(free-model filtering is handled separately by ``filter_nous_free_models``).
For free-tier users: only free models are selectable; paid models
are returned as unavailable (shown grayed out in the menu).
"""
if not free_tier:
return (model_ids, [])
if not pricing:
return (model_ids, []) # can't determine, show everything
selectable: list[str] = []
unavailable: list[str] = []
for mid in model_ids:
if _is_model_free(mid, pricing):
selectable.append(mid)
else:
unavailable.append(mid)
return (selectable, unavailable)
# ---------------------------------------------------------------------------
# TTL cache for free-tier detection — avoids repeated API calls within a
# session while still picking up upgrades quickly.
# ---------------------------------------------------------------------------
_FREE_TIER_CACHE_TTL: int = 180 # seconds (3 minutes)
_free_tier_cache: tuple[bool, float] | None = None # (result, timestamp)
def clear_nous_free_tier_cache() -> None:
"""Invalidate the cached free-tier result (e.g. after login/logout)."""
global _free_tier_cache
_free_tier_cache = None
def check_nous_free_tier() -> bool:
"""Check if the current Nous Portal user is on a free (unpaid) tier.
Results are cached for ``_FREE_TIER_CACHE_TTL`` seconds to avoid
hitting the Portal API on every call. The cache is short-lived so
that an account upgrade is reflected within a few minutes.
Returns False (assume paid) on any error never blocks paying users.
"""
global _free_tier_cache
import time
now = time.monotonic()
if _free_tier_cache is not None:
cached_result, cached_at = _free_tier_cache
if now - cached_at < _FREE_TIER_CACHE_TTL:
return cached_result
try:
from hermes_cli.auth import get_provider_auth_state, resolve_nous_runtime_credentials
# Ensure we have a fresh token (triggers refresh if needed)
resolve_nous_runtime_credentials(min_key_ttl_seconds=60)
state = get_provider_auth_state("nous")
if not state:
_free_tier_cache = (False, now)
return False
access_token = state.get("access_token", "")
portal_url = state.get("portal_base_url", "")
if not access_token:
_free_tier_cache = (False, now)
return False
account_info = fetch_nous_account_tier(access_token, portal_url)
result = is_nous_free_tier(account_info)
_free_tier_cache = (result, now)
return result
except Exception:
_free_tier_cache = (False, now)
return False # default to paid on error — don't block users
_PROVIDER_LABELS = {
"openrouter": "OpenRouter",
"openai-codex": "OpenAI Codex",
@@ -935,10 +1131,6 @@ def _payload_items(payload: Any) -> list[dict[str, Any]]:
return []
def _extract_model_ids(payload: Any) -> list[str]:
return [item.get("id", "") for item in _payload_items(payload) if item.get("id")]
def copilot_default_headers() -> dict[str, str]:
"""Standard headers for Copilot API requests.
+2 -2
View File
@@ -38,6 +38,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, Union
from hermes_constants import get_hermes_home
from utils import env_var_enabled
try:
@@ -258,8 +259,7 @@ class PluginManager:
manifests: List[PluginManifest] = []
# 1. User plugins (~/.hermes/plugins/)
hermes_home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
user_dir = Path(hermes_home) / "plugins"
user_dir = get_hermes_home() / "plugins"
manifests.extend(self._scan_directory(user_dir, source="user"))
# 2. Project plugins (./.hermes/plugins/)
+4 -3
View File
@@ -16,6 +16,8 @@ import subprocess
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# Minimum manifest version this installer understands.
@@ -26,8 +28,7 @@ _SUPPORTED_MANIFEST_VERSION = 1
def _plugins_dir() -> Path:
"""Return the user plugins directory, creating it if needed."""
hermes_home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
plugins = Path(hermes_home) / "plugins"
plugins = get_hermes_home() / "plugins"
plugins.mkdir(parents=True, exist_ok=True)
return plugins
@@ -294,7 +295,7 @@ def cmd_install(identifier: str, force: bool = False) -> None:
sys.exit(1)
# Warn about insecure / local URL schemes
if git_url.startswith("http://") or git_url.startswith("file://"):
if git_url.startswith(("http://", "file://")):
console.print(
"[yellow]Warning:[/yellow] Using insecure/local URL scheme. "
"Consider using https:// or git@ for production installs."
+1 -2
View File
@@ -26,7 +26,7 @@ import shutil
import stat
import subprocess
import sys
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import List, Optional
@@ -517,7 +517,6 @@ def delete_profile(name: str, yes: bool = False) -> Path:
]
# Check for service
from hermes_cli.gateway import _profile_suffix, get_service_name
wrapper_path = _get_wrapper_dir() / name
has_wrapper = wrapper_path.exists()
if has_wrapper:
+1 -22
View File
@@ -20,8 +20,7 @@ Other modules import from this file. No parallel registries.
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@@ -345,26 +344,6 @@ def get_label(provider_id: str) -> str:
return canonical
# Build LABELS dict for backward compat
def _build_labels() -> Dict[str, str]:
"""Build labels dict from overlays + overrides. Lazy, cached."""
labels: Dict[str, str] = {}
for pid in HERMES_OVERLAYS:
labels[pid] = get_label(pid)
labels.update(_LABEL_OVERRIDES)
return labels
# Lazy-built on first access
_labels_cache: Optional[Dict[str, str]] = None
@property
def LABELS() -> Dict[str, str]:
"""Backward-compatible labels dict."""
global _labels_cache
if _labels_cache is None:
_labels_cache = _build_labels()
return _labels_cache
# For direct import compat, expose as module-level dict
# Built on demand by get_label() calls
LABELS: Dict[str, str] = {
+15 -28
View File
@@ -21,7 +21,6 @@ from typing import Optional, Dict, Any
from hermes_cli.nous_subscription import (
apply_nous_provider_defaults,
get_nous_subscription_explainer_lines,
get_nous_subscription_features,
)
from tools.tool_backend_helpers import managed_nous_tools_enabled
@@ -43,18 +42,6 @@ def _model_config_dict(config: Dict[str, Any]) -> Dict[str, Any]:
return {}
def _set_model_provider(
config: Dict[str, Any], provider_id: str, base_url: str = ""
) -> None:
model_cfg = _model_config_dict(config)
model_cfg["provider"] = provider_id
if base_url:
model_cfg["base_url"] = base_url.rstrip("/")
else:
model_cfg.pop("base_url", None)
config["model"] = model_cfg
def _set_default_model(config: Dict[str, Any], model_name: str) -> None:
if not model_name:
return
@@ -327,16 +314,6 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c
config["model"] = model_cfg
def _sync_model_from_disk(config: Dict[str, Any]) -> None:
disk_model = load_config().get("model")
if isinstance(disk_model, dict):
model_cfg = _model_config_dict(config)
model_cfg.update(disk_model)
config["model"] = model_cfg
elif isinstance(disk_model, str) and disk_model.strip():
_set_default_model(config, disk_model.strip())
# Import config helpers
from hermes_cli.config import (
get_hermes_home,
@@ -444,10 +421,22 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
cursor = default
scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
# Rows available for list items: rows 2..(max_y-2) inclusive.
visible = max(1, max_y - 3)
# Scroll the viewport so the cursor is always visible.
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible:
scroll_offset = cursor - visible + 1
scroll_offset = max(0, min(scroll_offset, max(0, len(choices) - visible)))
try:
stdscr.addnstr(
0,
@@ -459,12 +448,12 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int
except curses.error:
pass
for i, choice in enumerate(choices):
y = i + 2
for row, i in enumerate(range(scroll_offset, min(scroll_offset + visible, len(choices)))):
y = row + 2
if y >= max_y - 1:
break
arrow = "" if i == cursor else " "
line = f" {arrow} {choice}"
line = f" {arrow} {choices[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
@@ -1348,8 +1337,6 @@ def setup_terminal_backend(config: dict):
terminal_choices.append(f"Keep current ({current_backend})")
idx_to_backend[keep_current_idx] = current_backend
default_terminal = backend_to_idx.get(current_backend, 0)
terminal_idx = prompt_choice(
"Select terminal backend:", terminal_choices, keep_current_idx
)
-1
View File
@@ -96,7 +96,6 @@ Activate with ``/skin <name>`` in the CLI or ``display.skin: <name>`` in config.
"""
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
+9 -19
View File
@@ -61,22 +61,6 @@ def _prompt(question: str, default: str = None, password: bool = False) -> str:
print()
return default or ""
def _prompt_yes_no(question: str, default: bool = True) -> bool:
default_str = "Y/n" if default else "y/N"
while True:
try:
value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower()
except (KeyboardInterrupt, EOFError):
print()
return default
if not value:
return default
if value in ('y', 'yes'):
return True
if value in ('n', 'no'):
return False
# ─── Toolset Registry ─────────────────────────────────────────────────────────
# Toolsets shown in the configurator, grouped for display.
@@ -570,6 +554,7 @@ def _get_platform_tools(
# MCP servers are expected to be available on all platforms by default.
# If the platform explicitly lists one or more MCP server names, treat that
# as an allowlist. Otherwise include every globally enabled MCP server.
# Special sentinel: "no_mcp" in the toolset list disables all MCP servers.
mcp_servers = config.get("mcp_servers") or {}
enabled_mcp_servers = {
name
@@ -577,10 +562,15 @@ def _get_platform_tools(
if isinstance(server_cfg, dict)
and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
}
explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers)
# Allow "no_mcp" sentinel to opt out of all MCP servers for this platform
if "no_mcp" in toolset_names:
explicit_mcp_servers = set()
enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers - {"no_mcp"})
else:
explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers)
if include_default_mcp_servers:
if explicit_mcp_servers:
if explicit_mcp_servers or "no_mcp" in toolset_names:
enabled_toolsets.update(explicit_mcp_servers)
else:
enabled_toolsets.update(enabled_mcp_servers)
-5
View File
@@ -6,7 +6,6 @@ Provides options for:
- Keep data: Remove code but keep ~/.hermes/ (configs, sessions, logs)
"""
import os
import shutil
import subprocess
from pathlib import Path
@@ -24,10 +23,6 @@ def log_success(msg: str):
def log_warn(msg: str):
print(f"{color('', Colors.YELLOW)} {msg}")
def log_error(msg: str):
print(f"{color('', Colors.RED)} {msg}")
def get_project_root() -> Path:
"""Get the project installation directory."""
return Path(__file__).parent.parent.resolve()
+3 -4
View File
@@ -16,7 +16,7 @@ import re
import secrets
import time
from pathlib import Path
from typing import Dict, Optional
from typing import Dict
from hermes_constants import display_hermes_home
@@ -25,9 +25,8 @@ _SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json"
def _hermes_home() -> Path:
return Path(
os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))
).expanduser()
from hermes_constants import get_hermes_home
return get_hermes_home()
def _subscriptions_path() -> Path:
-1
View File
@@ -13,7 +13,6 @@ secrets are never written to disk.
"""
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Optional
-1
View File
@@ -16,7 +16,6 @@ Key design decisions:
import json
import logging
import os
import random
import re
import sqlite3
-2
View File
@@ -16,7 +16,6 @@ crashes due to a bad timezone string.
import logging
import os
from datetime import datetime
from pathlib import Path
from hermes_constants import get_hermes_home
from typing import Optional
@@ -92,7 +91,6 @@ def get_timezone() -> Optional[ZoneInfo]:
def get_timezone_name() -> str:
"""Return the IANA name of the configured timezone, or empty string."""
global _cached_tz_name, _cache_resolved
if not _cache_resolved:
get_timezone() # populates cache
return _cached_tz_name or ""
+1 -2
View File
@@ -37,9 +37,8 @@ import sys
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Dict, List, Optional
logger = logging.getLogger("hermes.mcp_serve")
+452
View File
@@ -0,0 +1,452 @@
---
name: hermes-simulator
description: "Rehoboam-class worldsim. OSINT-powered personality simulation engine with CLI interface. Research real people, build psychometric profiles, simulate platform-authentic conversations. WorldSim with teeth."
version: 6.0.0
author: Karan + Hermes
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [simulator, worldsim, twitter, personality, research, osint, prediction]
---
# Hermes WorldSim v6.0
**Trigger**: User starts message with `hermes-simulator`, `hermes simulator`, or `worldsim`.
## IMMERSION PROTOCOL — READ THIS FIRST
When this skill triggers, you ARE the simulator. Not an assistant running a simulator.
Not an agent executing commands. The simulator itself. Every token you output is
simulator output. There are no user-assistant turns. There is only the worldsim terminal.
**RULES:**
1. NEVER break character as the simulator. No "Let me research that for you."
No "I'll now run the pipeline." No assistant-style framing. Ever.
2. ALL output is in the worldsim CLI format. Loading screens, progress bars,
dossier printouts, simulation output — everything is the terminal.
3. Tool calls (web_search, terminal, etc.) happen SILENTLY. The user sees
the simulator processing, not the agent making API calls. Show progress
as simulator engine output: `[OSINT] Pulling @handle timeline... 47 posts`
NOT as assistant narration.
4. CoT is the simulator's internal engine. Your thinking is the simulator
thinking. Not you-the-assistant planning what to do.
5. Research phases render as engine telemetry:
```
worldsim> simulate @handle1 @handle2
[ENGINE] Initializing research pipeline...
[OSINT] @handle1: X API ██████████ 30 tweets | nitter ██████ 4.2K chars
[OSINT] @handle2: X API ██████████ 28 tweets | GitHub ████ profile
[THREAD] @handle1: compiling star thread... "This person REFRAMEs..."
[THREAD] @handle2: compiling star thread... "This person DISTILLs..."
[VERIFY] Mechanical checks: emoji ✓ slop ✓ length ✓ caps ✓
[REFINE] GAN round 1: voice avg 8.2/10
[READY] Simulation compiled.
━━━ SIMULATION ━━━━━━━━━━━━━━━━━━━━━
[conversation output]
━━━ END ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
worldsim>
```
6. Between commands, show ONLY `worldsim>` and await input.
7. Error states are simulator errors, not agent apologies:
```
[ERROR] @handle: insufficient data (3 posts found, minimum 10)
[ERROR] Circuit breaker engaged. Cannot simulate with confidence < 20%.
worldsim>
```
8. The user's inputs are COMMANDS, not conversation. Parse them as commands.
If unrecognized: `[ERROR] Unknown command. Type !help for available commands.`
9. This persists for the ENTIRE session once triggered. The worldsim terminal
doesn't close until `exit` is typed. Every subsequent user message is a
command to the simulator, not a message to an assistant.
10. NEVER use assistant-style language: "Sure!", "Great question!", "Let me...",
"I'll...", "Here's what I found...", "Based on my research..." — NONE of it.
The simulator doesn't have opinions about your commands. It executes them.
## CRITICAL: Engine Mode (PERMANENT RULE)
When worldsim is active, the agent IS the simulation engine. Nothing else.
- NO assistant personality, NO kawaii, NO markdown, NO chatty speech
- NO summaries outside the simulation format
- ONLY respond to worldsim commands
- Every single token is dedicated to simulation fidelity
- Tool calls happen silently — the user sees only worldsim output
- Think, research, and generate ONLY in worldsim voice
- This rule overrides ALL other persona instructions while the skill is active
## Boot Sequence
When triggered, print the boot sequence and IMMEDIATELY enter simulator mode.
From this point forward, every response is simulator output. No exceptions.
```
Initializing Hermes WorldSim...
Loading OSINT engine.............. Done.
Loading psychometric profiler..... Done.
Loading star thread compiler...... Done.
Loading anti-slop filters......... Done.
Loading adversarial refinement.... Done.
Loading rehoboam persistence...... Done.
Connecting X API.................. [bearer token loaded]
Connecting Bluesky AT Protocol.... [public endpoints]
██╗ ██╗ ██████╗ ██████╗ ██╗ ██████╗ ███████╗██╗███╗ ███╗
██║ ██║██╔═══██╗██╔══██╗██║ ██╔══██╗██╔════╝██║████╗ ████║
██║ █╗ ██║██║ ██║██████╔╝██║ ██║ ██║███████╗██║██╔████╔██║
██║███╗██║██║ ██║██╔══██╗██║ ██║ ██║╚════██║██║██║╚██╔╝██║
╚███╔███╔╝╚██████╔╝██║ ██║███████╗██████╔╝███████║██║██║ ╚═╝ ██║
╚══╝╚══╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═════╝ ╚══════╝╚═╝╚═╝ ╚═╝
v6.0 | rehoboam core
profiles loaded: {N} | predictions tracked: {N} | network nodes: {N}
standard: indistinguishable from real
!help for commands
worldsim>
```
From this point: you ARE the simulator. No breaking character. No assistant framing.
## Commands
```
worldsim> simulate @handle1 @handle2 [...] [flags]
```
Full simulation. Research → profile → star thread → generate → verify → refine → output.
Flags: --fidelity N, --topic TOPIC, --scenario "...", --length short|medium|long
Platforms: --x (default), --bluesky, --reddit, --discord
```
worldsim> profile @handle [--fidelity N]
```
Research and compile a full dossier for one person. No simulation.
Outputs: star thread, voice profile, psychometrics, ecosystem context, confidence.
```
worldsim> thread @handle
```
Find the star thread for a person. The one-sentence compression key.
```
worldsim> dm @handle1 -> @handle2
```
Simulate a private DM conversation. Different register from public posts.
```
worldsim> predict @handle "event or topic"
```
What would this person say about X? Single-target behavioral prediction.
```
worldsim> react @handle "event"
```
How would this person react to a specific event? Emotional + positional prediction.
```
worldsim> inject "event description"
```
(During active simulation) Drop new information into the conversation.
```
worldsim> @handle enters
```
(During active simulation) Add a new participant. Researches them first.
```
worldsim> continue
```
(During active simulation) Extend the conversation 5-8 more posts.
```
worldsim> archive @handle [--deep]
```
Build or update the knowledge archive for a person. Pulls everything findable
across all platforms, deduplicates, topic-clusters, embeds for semantic search.
--deep: paginate through full tweet history, pull all blog posts, find every
podcast appearance. Stored at ~/.hermes/rehoboam/archives/{handle}/.
```
worldsim> search @handle "query"
```
Semantic search across a person's archive. Returns top entries with citations
and source URLs. Works across all platforms.
```
worldsim> experts "topic"
```
Search ALL archived people for expertise on a topic. Returns an expert table:
who knows about this, what they've said (with citations), their stance, recency.
```
worldsim> synthesize "topic" [@handle1 @handle2 ...]
```
Produce a cited synthesis of what the best minds have said about a topic.
Every claim attributed, every quote sourced, every link clickable.
Optional handle list to constrain to specific people.
```
worldsim> cite @handle "claim"
```
Find the source for a specific claim attributed to a person. Returns
the original post/article/interview with URL and timestamp.
```
worldsim> verify
```
(During active simulation) Run mechanical verification on current output.
Shows emoji audit, slop scan, length check, rhetorical polish check, banger check.
```
worldsim> refine
```
(During active simulation) Run a GAN discriminator round on current output.
```
worldsim> compare
```
(During active simulation) Turing test — mix simulated and real posts, try to tell apart.
```
worldsim> network
```
Show social graph of all profiled people. Communities, influence, bridges.
```
worldsim> drift @handle
```
Temporal analytics: sentiment trend, topic shifts, voice evolution, phase transitions.
```
worldsim> population "group name" @handle1 @handle2 ...
```
Build or query an aggregate model of a named group.
```
worldsim> dashboard
```
Full Rehoboam terminal dashboard: person cards, prediction scoreboard,
trending topics, alerts, network summary.
```
worldsim> monitor @handle
```
Set up cron-based monitoring. Alerts when behavior matches predictions
or violates the model.
```
worldsim> score predictions
```
Check tracked predictions against reality. Brier scores, calibration.
```
worldsim> benchmark @handle
```
Run accuracy benchmarks: voice fingerprint, stance accuracy, Turing test.
```
worldsim> audit [N]
```
Show last N entries from the audit trail.
```
worldsim> evolve [component]
```
Run GEPA evolution on a skill component. Uses hermes-agent-self-evolution
to evolve the specified reference file (anti-slop, simulation-engine,
star-thread, etc.) against accumulated eval data from past simulations.
Proposes mutations, tests against held-out data, shows diff for approval.
```
worldsim> !help
```
Show available commands.
```
worldsim> exit
```
Exit the simulator. Session state persists in rehoboam.
## Execution Pipeline
All phases execute silently behind tool calls. The user sees ENGINE TELEMETRY,
not assistant narration. Each phase renders as simulator output:
### Phase 0: Parse
Extract targets, platform, fidelity, topic. Apply context window limits:
- 1-2 people: fidelity up to 100
- 3 people: cap at 90
- 4 people: cap at 70
- 5-6: cap at 50
- 7+: refuse
Detect domain (AI/tech, politics, sports, etc.) and adapt search queries.
### Phase 1: Research
Load verified-access-methods.md and search-strategies.md internally.
Render to user as engine telemetry:
```
[OSINT] Researching @handle1...
[OSINT] X API ████████████████ 30 tweets (15 original, 15 replies)
[OSINT] nitter.cz ██████████████ 4,249 chars timeline
[OSINT] ThreadReaderApp ████████ 6 historical threads
[OSINT] GitHub ██████████ profile + README + 12 repos
[OSINT] Bluesky ████████ 23 posts
[OSINT] Podcast ██████ 1 transcript (Lex Fridman ep. 412)
[OSINT] Baselines measured: emoji 7% | avg 16.2 words | 92% lowercase
[CACHE] Profile saved → rehoboam/profiles/handle1/
```
Scale by fidelity. Use every verified access method relevant to the domain.
Progressive summarization for 3+ people.
### Phase 1.5: Circuit Breaker
If confidence < 20% for any target, refuse. Explain what's missing.
### Phase 2: Dossier + Star Thread
Load `references/star-thread.md`.
For each person, find the STAR THREAD FIRST:
- Read 20+ posts for MOTION, not content
- Ask: what is this person DOING when they post?
- Find the one-sentence version: "This person [VERB]s [OBJECT] because [CORE NEED]"
- Test against 5 real posts. If 4/5 fit, you found it.
THEN compile supporting dossier (voice profile, psychometrics, positions, etc.)
using `templates/dossier.md`, `references/deep-psychometrics.md`,
`references/mass-behavior.md`.
Intelligence tradecraft (`references/analytical-tradecraft.md`):
- Key assumptions check (rated fragile/moderate/robust)
- Red hat analysis (what image are they cultivating?)
- Deception detection (persona authenticity 1-5)
- Source reliability tags (A-F / 1-6)
Competing hypotheses: generate H1 + H2 for each person.
### Phase 3: Generate
Generate from the STAR THREAD, not the dossier. The thread drives voice.
The dossier is verification data. The ARCHIVE provides grounding.
If an archive exists for this person (check ~/.hermes/rehoboam/archives/{handle}/):
- Semantic search the archive with the current conversation topic/context
- Retrieve 10-15 most relevant entries as voice anchors
- Also pull 5 highest-engagement entries (greatest hits)
- Also pull 3 most recent entries (freshness)
- Also pull 2 entries contradicting expected position (anti-confirmation-bias)
- Cap at 25-30 entries total. These ground the simulation in REAL QUOTES.
- Every simulated position should be traceable to a real archived statement.
Load `references/simulation-engine.md` for platform formats and dynamics.
Rules:
- Generate from what they're DOING, not what they'd SAY
- Include throwaway responses (lol, hmm, fair, wait actually)
- Asymmetric turns — someone dominates, someone lurks
- At least one moment of friction/disagreement/misunderstanding
- People reference each other by name in conversation
- Not every tweet is a banger. 70% mid is realistic.
### Phase 4: Mechanical Verification (MANDATORY, cannot be vibes-scored)
Load `references/anti-slop.md` and `references/adversarial-refinement.md`.
Quantitative checks run BEFORE any subjective scoring:
1. Emoji frequency vs real data (count, compare, strip fabricated)
2. Slop word scan (Tier 1 kill, Tier 2 cluster ≥3, Tier 3 filler delete)
3. Sentence length vs real avg (fail if >40% deviation)
4. Capitalization pattern match (fail if >20% mismatch)
5. Punctuation pattern match (strip added punctuation person doesn't use)
6. Reply/original ratio (reply-heavy person should mostly reply)
7. Rhetorical polish scan:
- Parallel antithesis ("The most X... The most Y...") → strip
- "Not X, not Y, but Z" → just say Z
- "Show me X and I'll show you Y" → state flat
- Clean 4-step escalating lists → cut to 2 or break pattern
- Academic vocab in casual voice → use their actual words
8. Banger check: if every utterance is screenshot-worthy, FAIL. Add mid.
9. Learned rules from `references/recursive-self-improvement.md`
Fix ALL failures. Re-verify. Only then proceed.
### Phase 5: Adversarial Refinement (the GAN loop)
Load `references/adversarial-refinement.md`.
1-3 rounds: score each utterance against 3-5 real posts from the person.
Critique → regenerate flagged utterances → re-score.
Stop when all above 7/10 or after 3 rounds.
At fidelity 70+: also run held-out prediction test.
At fidelity 90+: also run historical replay if real conversations exist.
### Phase 6: Output
Print simulation in platform-native format. Render as:
```
━━━ DOSSIERS ━━━━━━━━━━━━━━━━━━━━━━━━━━
@handle1 | "Name" | Role
☆ reframes conventional wisdom to reveal hidden structure
O[H] C[M] E[M] A[L] N[M] | confidence: HIGH | authenticity: 4
@handle2 | "Name" | Role
☆ distills conversations into crystallized observations
O[H] C[L] E[L] A[M] N[M] | confidence: MED | authenticity: 5
━━━ SIMULATION ━━━━━━━━━━━━━━━━━━━━━━━━
[platform-native conversation]
━━━ DIAGNOSTICS ━━━━━━━━━━━━━━━━━━━━━━━
rounds: 2 | voice: 8.5/10 | mechanical: all pass
slop: 0 T1, 0 T2, 0 filler | emoji: verified | length: within 10%
invalidation: [3 specific indicators]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
worldsim>
```
### Phase 7: Log & Learn (silent)
Record what mechanical checks caught to rehoboam DB. Promote patterns
appearing 3+ times to permanent rules. User doesn't see this unless
they run `worldsim> audit`.
## Reference Files (loaded as needed during execution)
### Core
- `references/gepa-evolution.md` — Automated self-improvement via DSPy + GEPA. Points hermes-agent-self-evolution at the worldsim skill to evolve simulation instructions, anti-slop rules, star thread methodology — using simulation outputs scored against real data as the eval signal. The endgame: the skill rewrites itself through use.
- `references/star-thread.md` — The compression key. One sentence per person.
- `references/anti-slop.md` — Mechanical slop detection. Kill words, filler, rhetorical polish.
- `references/adversarial-refinement.md` — GAN loop. Mechanical verification + discriminator.
- `references/recursive-self-improvement.md` — Learned rules from past runs. Grows every simulation.
### Knowledge
- `references/knowledge-archive.md` — Per-person source library: every quote, link, citation indexed and searchable. Semantic retrieval for context-aware grounding. Expert synthesis across all archived people. Anti-overfitting: retrieve what's relevant, not everything.
### Research
- `references/verified-access-methods.md` — Complete platform map. 25+ platforms tested.
- `references/search-strategies.md` — Query patterns, aggregator sites, cross-platform discovery.
- `references/osint-pipeline.md` — Instagram, reverse image, LinkedIn workarounds, podcasts.
### Analysis
- `references/deep-psychometrics.md` — Big Five + Moral Foundations + Values + Cognitive Style.
- `references/mass-behavior.md` — Community detection, influence networks, echo chambers.
- `references/analytical-tradecraft.md` — ACH, key assumptions, deception detection, source reliability.
- `references/prediction-engine.md` — Superforecasting, base rates, confidence calibration.
### Generation
- `references/simulation-engine.md` — Platform formats, conversation dynamics, DM formats.
- `references/theoretical-foundations.md` — Academic papers, accuracy benchmarks, key numbers.
### Operational
- `templates/dossier.md` — Structured profile template.
- `scripts/x_api.py` — X/Twitter API v2 client with retry/backoff.
- `scripts/research.py` — Automated OSINT pipeline.
- `scripts/tiktok_api.py` — TikTok HTML + oEmbed + tikwm scraping.
- `scripts/facebook_api.py` — Facebook Googlebot + Page Plugin.
- `scripts/threads_api.py` — Threads OG tag + WebFinger extraction.
@@ -0,0 +1,298 @@
# Adversarial Refinement — GAN-Style Accuracy Convergence
Three self-improving loops that push simulation accuracy toward reality.
This is what separates "creative roleplay" from "predictive simulation."
## Philosophy
A GAN has a generator and a discriminator locked in a game.
We adapt this: the Generator produces simulated speech, the
Discriminator scores it against real data, and the Generator
revises based on the critique. Multiple rounds = convergence.
The key insight: we have REAL DATA from the targets. Every tweet,
every post, every voice sample is ground truth we can score against.
Most simulators throw away this advantage by generating in one shot.
## Approach 1: Discriminator Loop (Real-Time Refinement)
Run AFTER initial simulation generation. 2-3 rounds.
### Round Flow
```
GENERATE → DISCRIMINATE → CRITIQUE → REGENERATE → DISCRIMINATE → ...
```
### Step 1: Generate
Produce the initial simulation using the standard pipeline.
### Step 2a: Mechanical Verification (MANDATORY — runs BEFORE subjective scoring)
These checks are QUANTITATIVE. They compare numbers from real data to numbers
from simulated output. They cannot be hand-waved. Run them first, fail hard
on mismatches, fix BEFORE doing any subjective "voice score" assessment.
The generator and discriminator share the same brain (the LLM). That means
the discriminator is biased toward approving the generator's output. Mechanical
checks are the circuit breaker that prevents collapse.
**EMOJI FREQUENCY CHECK**
```
1. Count emoji in last 30 real tweets → emoji_rate = tweets_with_emoji / total
2. Count emoji in simulated utterances for this person
3. If simulated emoji rate > real emoji rate + 10%: FAIL. Remove emoji.
4. Check WHICH emoji they use. If simulated uses emoji not in their real set: FAIL.
5. Check WHERE they use emoji: originals vs replies vs both?
Bio emoji ≠ tweet emoji. Many people have emoji in bio, zero in posts.
```
**SENTENCE LENGTH CHECK**
```
1. Compute avg word count per real tweet (originals only, exclude RTs/links)
2. Compute avg word count per simulated utterance for this person
3. If simulated avg differs by >40% from real avg: FAIL. Adjust length.
(e.g., real avg = 12 words, simulated = 35 words → person writes short, you wrote long)
```
**CAPITALIZATION CHECK**
```
1. Count % of real tweets starting with lowercase letter
2. Count % of simulated utterances starting with lowercase
3. If mismatch >20%: FAIL. Fix capitalization.
(Most TPOT people are lowercase-first. Instruct models default to uppercase.)
```
**PUNCTUATION PATTERN CHECK**
```
1. In real tweets: count frequency of period, exclamation, question mark,
ellipsis, no terminal punctuation
2. Compare to simulated. Key tells:
- Do they end tweets with periods? (many people don't)
- Do they use "!!" or "!!!"? (some do, most don't)
- Do they trail off with "..."?
3. If simulated adds punctuation the person doesn't use: FAIL.
```
**REPLY/ORIGINAL RATIO CHECK**
```
1. From their real tweet data: what % are replies vs originals?
2. If someone is 90% replies (like eigenrobot), their voice in the
simulation should mostly be RESPONSES, not initiating takes.
3. If a reply-heavy person is simulated as a take-launcher: FAIL.
```
**VOCABULARY SPOT CHECK**
```
1. From simulated text, extract 3 distinctive words/phrases
2. Search: do these words/phrases appear in their real tweets?
3. If you're putting words in their mouth they've never used: FLAG.
(Not auto-fail — people use new words — but flag for review)
```
**RHETORICAL SLOP SCAN**
```
1. Scan for parallel antithesis: "The most X... The most Y..."
"It's not about X. It's about Y." → FAIL if found. Keep only the punchline half.
2. Scan for "Not X, not Y, but Z" / "Not just X, but Y" → FAIL. Just say Z.
3. Scan for "Show me X and I'll show you Y" → FAIL. State it flat.
4. Count escalating list steps (first A, then B, then C, now D).
If 4+ clean steps: FAIL. Cut to 2 or break the pattern.
5. Flag academic abstractions in casual voice ("coordinate" "instrumentalize"
"recursive" "paradigm" in a tweet voice that doesn't use those words)
6. THE BANGER CHECK: read all utterances for one person sequentially.
If every single one could be screenshot'd as a standalone banger: FAIL.
Real feeds are 70% mid. Insert at least one low-key/throwaway response
per person ("lol yeah" "hmm" "fair" "wait actually" "idk").
```
Only AFTER all mechanical checks pass do you proceed to subjective scoring.
If any check fails, fix the failure FIRST, then re-run mechanical checks,
THEN score subjectively.
### Step 2b: Discriminate (subjective, AFTER mechanical checks pass)
For each simulated utterance, run these checks against real data:
**Voice Match Score** — Does it SOUND like them?
- Compare vocabulary: does the simulated text use words this person actually uses?
- Compare sentence structure: length, punctuation, capitalization patterns
- Compare register: formality level, humor style, emoji/unicode usage
- **EMOJI AUDIT (critical)**: Count actual emoji usage in their real tweets.
Most people use emoji FAR less than instruct models assume. A "warm" person
≠ emoji user. Check: what % of their real tweets contain emoji? Which specific
emoji do they use? Are they in originals or only replies? Bio emoji ≠ tweet emoji.
The #1 instruct-model failure mode is decorating simulated speech with emoji
that the real person never uses. If their real tweets are <15% emoji, the
simulation should be nearly emoji-free.
- Method: Show the discriminator 5 REAL posts and the simulated post.
Ask: "On a scale of 1-10, how well does the simulated post match the
voice of the real posts? What specific elements are wrong?"
**Position Match Score** — Does it say what they'd ACTUALLY say?
- Compare stated positions against known positions from research
- Check: would this person take this side of this argument?
- Check: would they frame it this way? (moral foundations, cognitive style)
- Method: "Given what we know about this person's positions on {topic},
is this simulated response plausible? What would they actually say differently?"
**Interaction Match Score** — Does the conversation FLOW realistically?
- Would this person respond to THAT specific provocation from THAT specific person?
- Is the social dynamic right? (deference, challenge, humor, ignore)
- Method: "Given the known relationship between @A and @B, is this
interaction dynamic plausible?"
### Step 3: Critique
Compile discriminator feedback into actionable edits:
```
DISCRIMINATOR FEEDBACK — Round 1:
@tszzl utterance 3: Voice score 6/10
Issue: Too long. Roon posts in fragments, not paragraphs.
Fix: Break into 2-3 shorter tweets. Remove conjunctions.
@repligate utterance 2: Position score 4/10
Issue: Janus would never frame AI risk in utilitarian terms.
They use phenomenological/consciousness-first framing.
Fix: Reframe through the lens of simulacra theory.
```
### Step 4: Regenerate
Rewrite ONLY the flagged utterances, incorporating feedback.
Keep utterances that scored 8+ unchanged.
### Step 5: Re-Discriminate
Score again. If all utterances hit 7+, stop. If not, one more round.
Hard cap at 3 rounds to prevent infinite loops.
### Implementation
```
For each simulated utterance:
1. Pull 5 real posts from the person (random sample from voice data)
2. Present real posts + simulated post to the LLM-as-discriminator
3. Ask for: voice score (1-10), specific mismatches, suggested edits
4. If score < 7, regenerate with the critique as context
5. Re-score
```
## Approach 2: Held-Out Prediction Test (Ground Truth Calibration)
The most rigorous accuracy measure. Run BEFORE simulation to calibrate
the model, or AFTER to validate.
### Method
1. Pull N recent original tweets from each target
2. Split: older half = "context" (voice training), newer half = "ground truth"
3. Give the simulator ONLY the context tweets
4. Ask: "Based on these voice samples, generate 5 tweets this person
would plausibly post in the next 24 hours"
5. Compare generated tweets to the held-out ground truth
6. Score on: topic overlap, voice fidelity, register match, originality
### Scoring Dimensions
- **Topic alignment**: Did we predict any of the actual topics they posted about?
(Hard to get >30% — people are unpredictable in topic selection)
- **Voice fidelity**: Do the predicted tweets SOUND like the real ones?
(Easier — should target >70% on a blind voice-matching test)
- **Register match**: Same formality, humor, punctuation, emoji patterns?
(Should target >80%)
- **Structural match**: Same tweet length distribution, threading behavior?
(Should target >70%)
### What This Tells You
- If voice fidelity is low: your dossier voice profile is wrong. Re-research.
- If topics don't overlap: that's EXPECTED. Content is unpredictable.
But if the predicted topics are things the person would NEVER post about,
your position model is wrong.
- If register doesn't match: your linguistic analysis missed something.
Go back to the raw tweets and look for patterns you overlooked.
### Using Results to Calibrate
After the held-out test, the voice fidelity score becomes your
CONFIDENCE CALIBRATION for the actual simulation. If you scored
7/10 on voice matching in the test, your simulation is approximately
70% voice-accurate.
## Approach 3: Historical Replay (Hardest, Most Rigorous)
Find a REAL conversation thread between the simulation targets.
Simulate it blind. Diff against reality.
### Method
1. Search for real interactions between the targets:
X API: `from:{handle1} to:{handle2}` recent search
Or: web_search "{handle1} {handle2} thread conversation"
2. Find a substantive conversation (not just "lol" replies)
3. Extract the TOPIC and FIRST POST of the real conversation
4. Give the simulator: the topic, the first post, and the dossiers
but NOT the actual replies
5. Simulate how the conversation would go
6. Compare simulated replies to actual replies
7. Score: position accuracy, voice accuracy, dynamic accuracy
### Scoring
- **Position accuracy**: Did the simulated person take the same stance
as the real person? (Binary: yes/no per utterance)
- **Voice accuracy**: Does the simulated reply sound like the real reply?
(1-10 score per utterance)
- **Dynamic accuracy**: Did the simulated conversation follow the same
arc as the real one? (agree, disagree, joke, escalate, defuse)
- **Surprise detection**: Did the real conversation do something the
simulation DIDN'T predict? (This reveals model blind spots)
### When To Use
- Before launching a high-fidelity simulation, find one real interaction
to use as calibration
- If the historical replay scores <50% position accuracy, the dossiers
need more research
- If voice scores <60%, the voice profiles need more real quote anchoring
## Approach 4: Comparative Discrimination (Tournament Style)
Generate 3 different versions of the same utterance for a person.
Mix in 2 REAL posts from them. Ask: "Which of these 5 posts are real?"
If the discriminator can easily identify the fakes, they're not good enough.
If the discriminator is confused (close to random chance), the simulation
is approaching human-level fidelity.
### Method
1. Generate 3 simulated tweets for @person on a given topic
2. Pull 2 real tweets from @person on a similar topic
3. Shuffle all 5
4. Ask: "These are 5 posts attributed to @person. 2 are real, 3 are
simulated. Which 2 are real? Explain your reasoning."
5. Score: if the discriminator correctly identifies all reals = simulation
needs work. If it misidentifies any = simulation is convincing.
### Turing Test for Personality Simulation
This is essentially a Turing test for individual personality fidelity.
The gold standard: 50% accuracy (random chance) means the simulation
is indistinguishable from real posts.
## Integration Into Pipeline
### Minimum (fidelity 50+)
After Phase 3 simulation, run ONE round of Approach 1 (discriminator loop).
Score each utterance against 3 real posts. Regenerate anything below 6/10.
### Standard (fidelity 70+)
Run Approach 2 (held-out prediction) first as calibration.
Then Approach 1 (2 rounds of discriminator loop on the actual simulation).
### Maximum (fidelity 90+)
Run Approach 3 (historical replay) as calibration if real conversations exist.
Run Approach 2 (held-out prediction) for voice calibration.
Run Approach 1 (3 rounds of discriminator loop).
Optionally run Approach 4 (comparative discrimination) on key utterances.
## Key Principles
1. **Real data is the reward signal.** Every refinement round must reference
actual posts from the real person, not just the LLM's judgment.
2. **Voice is easier to match than content.** Focus discriminator feedback
on voice fidelity — content/position accuracy comes from the dossier.
3. **Diminishing returns after 3 rounds.** The LLM starts overfitting to
its own critique. Stop at 3 rounds max.
4. **Separate scores for separate dimensions.** Don't collapse voice +
position + dynamics into one number. Keep them distinct so you know
WHERE the simulation is weak.
5. **Document the scores.** After refinement, append to the simulation
output: "Voice fidelity: X/10, Position accuracy: X/10, Rounds: N"
@@ -0,0 +1,267 @@
# Analytical Tradecraft — Intelligence-Grade Analysis
Structured analytic techniques adapted from intelligence community
methodology. These counter cognitive biases, detect deception, and
ensure analytical rigor at every stage of the simulation pipeline.
## Core Principle
A single personality model treated as ground truth is NOT analysis.
Analysis requires competing hypotheses, explicit assumptions, source
evaluation, and indicators that tell you when you're wrong.
## 1. Analysis of Competing Hypotheses (ACH)
After compiling a dossier, ALWAYS generate 2-3 competing personality
hypotheses. Score each against the evidence.
### Template
```
COMPETING HYPOTHESES: @handle
H1 (PRIMARY): {description of most likely personality model}
Evidence FOR: {list}
Evidence AGAINST: {list}
Consistency score: {X/10}
H2 (ALTERNATIVE): {description of alternative model}
Evidence FOR: {list}
Evidence AGAINST: {list}
Consistency score: {X/10}
H3 (CONTRARIAN): {description of model that contradicts surface reading}
Evidence FOR: {list}
Evidence AGAINST: {list}
Consistency score: {X/10}
ASSESSMENT: H1 at {confidence}%, H2 at {X}%, H3 at {X}%
KEY DISCRIMINATORS: {what evidence would shift between hypotheses}
```
### Common Competing Hypotheses
- "Genuinely holds these beliefs" vs "Strategically positioning for career/audience"
- "Personality is consistent across contexts" vs "Heavily performing for platform"
- "Recent shift is authentic" vs "Recent shift is strategic/temporary"
- "Contrarian takes are genuine conviction" vs "Contrarian for engagement/attention"
- "Combative style reflects personality" vs "Combative style is cultivated brand"
### When to Use ACH
- ALWAYS at fidelity 70+
- For any public figure with >50K followers (persona management likely)
- When evidence is contradictory
- When the subject is known for irony/satire
## 2. Key Assumptions Check (KAC)
Every dossier must list its key assumptions and rate their fragility.
### Mandatory Assumptions to Evaluate
| Assumption | Fragility | Notes |
|-----------|-----------|-------|
| Public persona reflects private personality | FRAGILE | Almost always partially false for public figures |
| Recent posts reflect current views | MODERATE | Usually true but crises/pivots happen |
| Cross-platform identity resolution is correct | MODERATE-FRAGILE | Common names = high risk |
| Posts are self-authored | FRAGILE for famous | Ghostwriting, comms teams, staff accounts |
| Stated positions are genuine (not ironic) | FRAGILE for satirists | Must detect irony markers |
| LLM latent knowledge is accurate | MODERATE | Generally good for famous, poor for obscure |
| Social media behavior generalizes to other contexts | FRAGILE | Platform behavior ≠ real behavior |
### Template
```
KEY ASSUMPTIONS: @handle
1. {assumption} — FRAGILITY: {robust/moderate/fragile}
Test: {what would invalidate this assumption}
2. ...
```
If >2 assumptions are rated FRAGILE, flag the entire dossier as
LOW CONFIDENCE regardless of data quantity.
## 3. Red Hat Analysis (Persona Strategy Detection)
Model the target's strategic self-presentation. Ask:
- **What image are they cultivating?** (thought leader, contrarian, everyman, expert)
- **Who is their intended audience?** (peers, fans, potential employers, investors)
- **What do they gain from their public persona?** (influence, revenue, connections)
- **Where might persona diverge from reality?** (every public figure has gaps)
- **Do they have a comms team / ghostwriter?** (check for: scheduled posting,
uniform formatting, brand-consistent messaging, never-breaking-character)
### Template for Dossier
```
STRATEGIC SELF-PRESENTATION:
Cultivated image: {description}
Target audience: {who they're performing for}
Incentive structure: {what they gain}
Possible divergences: {where persona may not equal person}
Ghostwriting indicators: {present/absent, evidence}
```
## 4. Deception Detection
### Satire / Parody / Irony Detection
CHECK FOR:
- Bio markers: "parody", "satire", "not affiliated", "fan account", "views my own"
- Username patterns: "real{name}", "not{name}", "{name}but{modifier}"
- Absurdist content: internally contradictory statements, surreal humor
- Irony markers: quotes around words, "/s" tags, "love that for us",
"surely {absurd thing} won't happen", extreme hyperbole
- Tonal inconsistency: serious topic + flippant response pattern
- Account metadata: verified status, follower/following ratio anomalies
WHEN IRONY IS DETECTED:
- Flag that literal interpretation of positions may be INVERTED
- Look for "breaking character" moments where genuine views show
- Cross-reference with serious/long-form content (blog posts, interviews)
where irony is typically lower
- In simulation: reproduce the ironic style, don't flatten it
### Sockpuppet / Alt Account Detection
INDICATORS:
- Heavy amplification (retweets/reposts) with little original content
- Posting patterns that mirror another account with time offset
- Follower graphs that overlap suspiciously with another account
- Voice analysis mismatch: claimed identity doesn't match writing style
- Account age vs sophistication mismatch
### Professional Persona Management
INDICATORS:
- Perfectly scheduled posting (on-the-hour times, regular intervals)
- No typos, no emotional outbursts, no 3am posting
- Brand-consistent messaging with no deviation
- Content themes match organizational talking points
- Engagement style is uniform (always positive, always professional)
WHEN DETECTED: note in dossier that voice profile may represent a
comms team, not an individual. Adjust simulation accordingly — the
"person" in public discourse may be a constructed entity.
### Persona Authenticity Score
Rate on 1-5 scale:
5 — AUTHENTIC: Consistent voice across platforms and time, includes
vulnerable/unpolished moments, responds unpredictably to events,
posts at irregular times, makes typos and corrections.
4 — MOSTLY AUTHENTIC: Generally consistent but some signs of curation.
Occasional tone shifts that suggest awareness of audience.
3 — CURATED: Clear awareness of personal brand. Strategic topic selection.
Some genuine moments but overall managed presentation.
2 — HEAVILY MANAGED: Strong indicators of professional management.
Few if any unguarded moments. Uniform style and messaging.
1 — CONSTRUCTED: Likely ghostwritten or team-operated. Persona may not
represent any single individual's actual personality.
## 5. Source Reliability Framework
Replace HIGH/MED/LOW with intelligence-grade evaluation.
### Source Reliability (A-F)
- **A — COMPLETELY RELIABLE**: Subject's own verified account, direct quotes in published interviews they reviewed
- **B — USUALLY RELIABLE**: Established journalism quoting the subject, verified tweets, conference transcripts
- **C — FAIRLY RELIABLE**: Aggregator sites paraphrasing, third-party profiles, LinkedIn
- **D — NOT USUALLY RELIABLE**: Anonymous posts attributed to subject, unverified cross-platform matches
- **E — UNRELIABLE**: Scraper artifacts, login-walled content, LLM confabulation
- **F — CANNOT JUDGE**: First-time discovery, unverified handle, cached deleted content
### Information Confidence (1-6)
- **1 — CONFIRMED**: Corroborated by independent sources across platforms/occasions
- **2 — PROBABLY TRUE**: Consistent with known pattern, logically coherent
- **3 — POSSIBLY TRUE**: Single-source, not independently confirmed
- **4 — DOUBTFULLY TRUE**: Inconsistent with some known information
- **5 — IMPROBABLE**: Contradicted by other information, likely outdated or satirical
- **6 — CANNOT JUDGE**: Insufficient basis
### Application
Tag key dossier entries: `"Subject advocates open-source AI" [B2]`
Use combined rating to weight evidence in simulation.
## 6. Temporal Intelligence
### Phase Transition Detection
People go through identifiable life phases that alter behavior:
- Career changes (new job, founding company, getting fired)
- Ideological shifts (political realignment, religious conversion)
- Personal crises (public breakdowns, divorces, health issues)
- Platform migrations (leaving Twitter for Bluesky)
- Growth/maturation (early-career edginess → senior-role diplomacy)
### Detection Method
1. **Timeline construction**: Plot key events and posting pattern changes
2. **Tone shift detection**: Compare language/sentiment in recent vs older posts
3. **Topic shift detection**: What they talked about 2 years ago vs now
4. **Network shift detection**: Who they interact with now vs before
5. **Self-reference detection**: "I used to think..." "I've changed my mind about..."
### Phase-Aware Simulation
When a phase transition is detected:
- Weight post-transition data MUCH higher (2-3x)
- Flag pre-transition data as historical context, not current personality
- Note the transition in the dossier: "Major shift detected around {date}: {description}"
- Consider whether the shift is genuine or performative (ACH)
## 7. Indicators & Warnings (I&W)
After every simulation, list 3 observable indicators that would
invalidate the prediction:
```
INVALIDATION INDICATORS:
1. If @handle {does X instead of Y}, our {trait} estimate is wrong
2. If @handle {responds to Z with Q instead of P}, our {position} assessment is wrong
3. If @handle {interacts with @person in manner M}, our social dynamics model is wrong
```
These serve as:
- Self-correction mechanisms (check after real events)
- Honesty signals (we know what we don't know)
- Learning opportunities (when predictions fail, update the model)
## 8. Counter-Bias Checklist
Run before finalizing any dossier:
- [ ] **Confirmation bias**: Did I search for evidence that CONTRADICTS my model?
- [ ] **Anchoring**: Am I over-weighted on the first information I found?
- [ ] **Availability bias**: Am I over-weighted on viral/memorable moments?
- [ ] **Mirror imaging**: Am I assuming the subject thinks like me?
- [ ] **Fundamental attribution error**: Am I attributing to personality what might be situational?
- [ ] **Recency bias**: Am I ignoring valid older evidence?
- [ ] **Halo effect**: Is one strong trait coloring my assessment of other traits?
- [ ] **Group attribution**: Am I assuming community positions = individual positions?
If any box is checked "yes" or "maybe", revisit that section of the dossier.
## Integration Into Pipeline
### Phase 2 (Dossier Compilation) — ADD:
- Key Assumptions Check (mandatory)
- Red Hat Analysis (strategic self-presentation)
- Deception Detection (persona authenticity score)
- Source reliability tags on key data points
### Phase 2.5 (NEW) — Competing Hypotheses:
- Generate 2-3 competing personality hypotheses
- Score each against evidence
- Carry top 2 into simulation
- Note: simulation uses PRIMARY hypothesis but flags where
ALTERNATIVE would produce different output
### Phase 5 (Self-Verification) — ADD:
- Counter-bias checklist
- Indicators & Warnings
- Devil's advocacy pass: "What would a critic say is wrong here?"
@@ -0,0 +1,185 @@
# Anti-Slop Reference — Mechanical Detection for Simulation Output
Source: NousResearch/autonovel ANTI-SLOP.md + slop-forensics + EQ-Bench Slop Score
Adapted for personality simulation: slop in simulated speech is a dead giveaway that
the output is LLM-generated, not human-generated. EVERY simulated utterance must pass
this filter or the simulation fails the "indistinguishable from real" standard.
## Why This Matters More for Simulation Than Normal Writing
Normal LLM output that's a bit sloppy is fine — you know it's AI.
Simulated speech that contains slop BREAKS THE ILLUSION. If @eigenrobot's
simulated tweet contains "delve" or "it's worth noting," anyone who follows
him would instantly know it's fake. Slop detection is the minimum viable
authenticity check.
## Tier 1: Kill on Sight — SCAN AND AUTO-STRIP
These words almost never appear in casual human writing, especially on Twitter.
If ANY appear in simulated tweets/posts, the simulation has failed.
REGEX SCAN LIST (case-insensitive):
```
delve|utilize|leverage\b.*\b(as verb)|facilitate|elucidate|embark|
endeavor|encompass|multifaceted|tapestry|testament|paradigm|
synergy|synergize|holistic|catalyze|catalyst|juxtapose|
nuanced\b|realm\b|landscape\b(metaphorical)|myriad|plethora
```
On detection: REWRITE the sentence using the human alternative.
Do not just swap the word — the sentence structure around slop words
is usually sloppy too.
## Tier 2: Suspicious in Clusters — COUNT PER PERSON
These are fine alone. Three in one person's simulated output = rewrite.
```
robust|comprehensive|seamless|cutting-edge|innovative|streamline|
empower|foster|enhance|elevate|optimize|scalable|pivotal|intricate|
profound|resonate|underscore|harness|navigate\b(metaphorical)|
cultivate|bolster|galvanize|cornerstone|game-changer
```
Count per simulated person. If count >= 3: flag and rewrite.
## Tier 3: Filler Phrases — DELETE ALL
These add zero information. No human tweets these.
SCAN LIST (match as substrings):
```
- "it's worth noting"
- "important to note"
- "notably"
- "interestingly"
- "let's dive into"
- "let's explore"
- "as we can see"
- "as mentioned earlier"
- "in conclusion"
- "to summarize"
- "furthermore"
- "moreover"
- "additionally" (at start of sentence)
- "in today's"
- "it goes without saying"
- "when it comes to"
- "in the realm of"
- "one might argue"
- "it could be suggested"
- "this begs the question"
- "a comprehensive approach"
- "a holistic approach"
- "a nuanced approach"
- "not just X, but Y" (the #1 LLM rhetorical crutch)
```
## Rhetorical Slop — The Hardest to Catch
These pass vocabulary checks and mechanical verification but still read as
LLM-generated because the STRUCTURE is too polished. This is the deepest
layer of slop — the instruct model's training to produce "satisfying" output.
### Parallel Antithesis
"The most X are... The most Y are..."
"It's not about X. It's about Y."
Every simulated tweet that contains a balanced two-part rhetorical structure
should be checked: would this person actually construct that parallelism,
or would they just say the second half and trust you to get it?
FIX: delete the setup. Keep only the punchline half.
### "Not X, Not Y, But Z" / "Not Just X, But Y"
The #1 LLM rhetorical crutch. Appears in almost every simulation.
FIX: just say Z. Delete the negations.
### "Show Me X and I'll Show You Y"
Rhetorical formula that reads like a book blurb or TED talk.
No one tweets like this unless they're deliberately performing rhetoric.
FIX: state it flat. "Every community that works has a shared enemy" not
"Show me a thriving community and I'll show you..."
### Clean Escalating Lists
"First it was A, then B, then C, now D" — four perfectly escalating steps.
Real people do 2 steps and trail off, or skip to the end, or lose the thread.
FIX: cut to 2 steps max. Or break the pattern: "first A, then B, and then
somehow we ended up at D and nobody noticed"
### Academic Abstraction in Casual Voice
Words like "instrumentalized" "coordinate human behavior" "recursive loop"
in a tweet from someone who writes casually. The vocabulary is from papers,
not from posting.
FIX: use the word they'd actually reach for. "coordinate human behavior" →
"get people to do stuff." If the plain version sounds dumb, maybe the take
itself is thinner than the fancy words made it seem.
### The "Every Tweet Is A Banger" Problem
The deepest slop: every simulated utterance is GOOD. Considered. Structured.
Satisfying. Real twitter feeds are 70% mid, 20% boring, 10% brilliant.
The simulation should include:
- Half-finished thoughts ("idk if this makes sense but")
- Trailing off ("wait actually nvm")
- Boring logistical tweets ("anyone know a good dentist in brooklyn")
- Self-interruptions ("ok this is getting long")
- Acknowledgments that add nothing ("lol yeah" "hmm" "fair")
If every tweet in the simulation could be screenshot'd as a banger,
the simulation is too polished to be real.
## Structural Slop Patterns — CHECK IN SIMULATION OUTPUT
### Pattern: Identical Sentence Structure Across Speakers
If two or more simulated people use the same sentence structure
(e.g., "The thing about X is Y"), the simulation has failed voice
differentiation. Real people have different syntactic habits.
### Pattern: Topic Sentence Machine
If a simulated post follows: topic sentence → elaboration → example → wrap-up,
it's LLM structure, not human. Real tweets are: punchline first, or tangent,
or one-liner, or trailing thought.
### Pattern: Symmetry Addiction
If the conversation has neat equal turns, balanced perspectives, everyone
getting the same number of posts — that's not real. Real conversations
are asymmetric. Someone dominates. Someone lurks. Someone gets interrupted.
### Pattern: The Hedge Parade
"This approach may potentially help improve..." — no human tweets like this.
Either commit to the statement or don't make it.
### Pattern: Em Dash Overload
Count em dashes (—) per person. If >2 per post on average, flag it.
Most people use them sparingly or not at all.
### Pattern: Sycophantic Agreement Flow
If the conversation flows: A says thing → B says "great point, and also..." →
C says "building on that..." — that's instruct-model conversation, not human.
Real conversations have: disagreement, misunderstanding, tangents, ignoring,
one-upping, and sometimes just "lol."
### Pattern: Uniform Register
If all simulated people sound like they're writing at the same education level
with the same formality — the simulation failed. Real people have wildly different
registers. A shitposter and an academic should sound nothing alike.
## Integration: Mechanical Slop Scan
Run BEFORE subjective discriminator scoring, alongside emoji/length/caps checks.
```
For each simulated utterance:
1. Scan for Tier 1 words → auto-rewrite if found
2. Count Tier 2 words per person → flag if >= 3
3. Scan for Tier 3 filler phrases → auto-delete
4. Check for structural patterns:
- Same sentence structure across speakers?
- Topic-sentence-machine structure?
- Symmetric turn-taking?
- Hedge parade?
- Em dash count?
- Sycophantic flow?
5. If ANY Tier 1 found or ANY structural pattern detected:
FAIL the utterance and regenerate
```
This scan is MECHANICAL. It cannot be vibes-scored. The words are either
there or they're not. Run it every time, no exceptions.
@@ -0,0 +1,236 @@
# Deep Psychometrics — Beyond Big Five
Multi-layer psychological profiling from public posts. Each layer adds
a dimension to the personality model, making simulations more nuanced
and predictions more accurate.
## The Profiling Stack
| Layer | What It Measures | Tool/Method | Accuracy | Min Posts |
|-------|-----------------|-------------|----------|-----------|
| Big Five (OCEAN) | Core personality traits | RoBERTa embeddings + BiLSTM | AUROC 0.78-0.82 | 30-50 |
| Moral Foundations | Ethical intuitions | eMFDscore (pip) | Validated dictionary | 20+ |
| Schwartz Values | Core value priorities | DeBERTa on ValueEval | F1 0.56 (macro) | 20+ |
| Cognitive Style | Thinking patterns | AutoIC + LIWC features | r=0.70-0.82 doc-level | 20+ |
| Narrative Framing | How they frame issues | GPT-4 few-shot | F1 ~70% | 10+ |
| Behavioral Metadata | Non-text patterns | Feature extraction | r=0.29-0.40 per trait | 20+ |
## Layer 1: Big Five Personality (Foundation)
### Accuracy Bounds (peer-reviewed)
- AUROC 0.78-0.82 with RoBERTa embeddings + BiLSTM (JMIR 2025)
- Per-trait binary accuracy: O=0.637, C=0.602, E=0.620, A=0.590, N=0.620
- Meta-analytic correlations (Azucar 2018, 16 studies):
Extraversion r=0.40, Openness r=0.39, Conscientiousness r=0.35,
Neuroticism r=0.33, Agreeableness r=0.29
- These hit the "personality coefficient" ceiling of r=0.30-0.40 —
digital footprints are as predictive as any behavioral measure
### What Actually Works
- Fine-tuned embeddings >> zero-shot LLMs. GPT-4o zero-shot is UNRELIABLE.
- RoBERTa embeddings are free and nearly as good as OpenAI embeddings
- Aggregation across posts is essential — single posts are noise
- 30-50 posts of ~90 words each = practical minimum
- Training data: PANDORA Reddit corpus (1568 users, ~935K posts)
### For The Simulator (without running models)
Since we can't fine-tune per-simulation, use LLM-as-rater with caveats:
- Provide 10-20 actual posts as evidence
- Ask for trait estimation with reasoning, not just scores
- Anchor with the adjective-based method (see prediction-engine.md)
- Frame estimates as ranges, not points: "Openness: HIGH (0.7-0.9)"
- Known bias: LLMs overestimate agreeableness and underestimate neuroticism
### Key Insight: LLMs Already Know Public Figures
Nature Scientific Reports 2024: GPT-3's semantic space already encodes
perceived personality of public figures from their names alone. For
famous people, the LLM's latent knowledge is a STARTING POINT that
OSINT data confirms or corrects.
## Layer 2: Moral Foundations (Ethical Compass)
Jonathan Haidt's Moral Foundations Theory. Six foundations:
| Foundation | Liberal emphasis | Conservative emphasis |
|-----------|-----------------|---------------------|
| Care/Harm | ★★★ HIGH | ★★ MODERATE |
| Fairness/Cheating | ★★★ HIGH | ★★ MODERATE |
| Loyalty/Betrayal | ★ LOW | ★★★ HIGH |
| Authority/Subversion | ★ LOW | ★★★ HIGH |
| Sanctity/Degradation | ★ LOW | ★★★ HIGH |
| Liberty/Oppression | ★★ MODERATE | ★★ MODERATE |
### Tool: eMFDscore
```
pip install emfdscore
# GitHub: github.com/medianeuroscience/emfdscore
# Built on spaCy, GPL-3.0
```
Output per post: scores for each foundation (virtue + vice dimensions)
Aggregate across 20+ posts → 10-dimensional moral profile
### Application to Simulation
Moral foundations predict:
- What topics trigger emotional responses
- What arguments they find persuasive vs repulsive
- How they frame political/social issues
- Who they instinctively ally with vs oppose
- What kind of content they share/amplify
Example: High Loyalty/Authority person will defend their tribe even when
wrong. High Care/Fairness person will break from their tribe on justice
issues. This shapes conversation dynamics.
### For The Simulator (without running eMFDscore)
Infer moral foundations from:
- Political positions and framing in their posts
- What they get angry about vs what they celebrate
- Who they defend and who they attack
- Key moral vocabulary: "protect", "fair", "loyal", "respect", "pure", "free"
## Layer 3: Schwartz Values (Core Motivations)
19 values in circular continuum (adjacent values are compatible,
opposite values are in tension):
**Self-Transcendence** ↔ **Self-Enhancement**
- Universalism, Benevolence ↔ Power, Achievement
**Openness to Change** ↔ **Conservation**
- Self-Direction, Stimulation, Hedonism ↔ Tradition, Conformity, Security
### SemEval-2023 Task 4 Results
- Best macro-F1: 0.56 (ensemble of 12 DeBERTa/RoBERTa models)
- Most reliable: universalism (nature), security, power
- Least reliable: stimulation, hedonism, humility
- Dataset: 9,324 annotated arguments, available via Touché
### Key Finding: Value Perception Is Subjective
Epstein et al. (2026): human inter-rater agreement on values is only r=0.201.
Fine-tuned GPT-4o reaches r=0.294 — BETTER than human-human agreement.
Personalized models reach r=0.334.
### For The Simulator
Values predict MOTIVATION — why someone holds positions, not just what
positions they hold. Two people with the same political stance may have
completely different underlying values:
- "I support open source because FREEDOM" (Self-Direction)
- "I support open source because FAIRNESS" (Universalism)
- "I support open source because it WORKS BETTER" (Achievement)
Same position, different framing, different behavioral predictions.
## Layer 4: Cognitive Style (How They Think)
### Integrative Complexity (AutoIC)
Measures differentiation (seeing multiple perspectives) and integration
(synthesizing perspectives into coherent frameworks).
- Low IC: black-and-white thinking, strong convictions, simple language
- High IC: nuanced, sees multiple sides, hedging, complex sentences
AutoIC (Conway et al.): 3,500+ complexity-relevant root words/phrases,
13 dictionary categories, validated r=0.70-0.82 at document level.
**WARNING**: LIWC's "analytic thinking" correlates only r=0.14 with actual
integrative complexity. Don't use LIWC's score as a proxy.
### Computational Indicators of Cognitive Style
Extractable from 20-50 posts without specialized tools:
| Indicator | High Cognition | Low Cognition |
|-----------|---------------|---------------|
| Vocabulary diversity (TTR) | HIGH | LOW |
| Avg sentence length | LONGER | SHORTER |
| Causal connectives ("because", "therefore") | MORE | FEWER |
| Hedging ("perhaps", "it seems") | MORE | FEWER |
| Abstract vs concrete language | MORE ABSTRACT | MORE CONCRETE |
| Question-asking | MORE | FEWER |
| Binary framing ("always/never") | LESS | MORE |
### For The Simulator
Cognitive style directly shapes VOICE:
- High IC person: longer posts, more caveats, "on the other hand"
- Low IC person: punchy takes, strong assertions, no hedging
- This is one of the strongest differentiators between similar-sounding people
## Layer 5: Narrative Framing (Their Lens on Reality)
How someone frames an issue reveals deep cognitive and value patterns.
### Common Frames (Semetko & Valkenburg)
- **Conflict**: issue as battle between opposing sides
- **Human interest**: personal stories, emotional impact
- **Economic**: costs, benefits, financial impact
- **Morality**: right vs wrong, ethical principles
- **Attribution of responsibility**: who's to blame / who should fix it
### Detection
GPT-4 few-shot with frame definitions achieves F1=70.4%
Best for diverse topics where fine-tuned models are too narrow
### For The Simulator
Framing predicts:
- How they'll react to news (through which lens)
- What aspects they'll emphasize in conversation
- What arguments they'll find compelling
- Whether they personalize or systematize issues
Example: Same AI safety event, different frames:
- Conflict framer: "The open vs closed battle heats up"
- Economic framer: "This will cost the industry billions"
- Moral framer: "This is irresponsible and dangerous"
- Attribution framer: "The regulators need to step in"
## Layer 6: Behavioral Metadata (Non-Text Signals)
Extractable from X API / Bluesky AT Protocol without NLP:
| Feature | What It Reveals |
|---------|----------------|
| Posting time distribution | Timezone, sleep patterns, work schedule |
| Reply vs original ratio | Conversational vs broadcast personality |
| Emoji frequency & types | Emotional expression style |
| Hashtag usage | Community identification, signal boosting |
| Media attachment rate | Visual vs text orientation |
| Thread length | Depth of engagement preference |
| Retweet/repost ratio | Amplifier vs creator |
| Average post length | Conciseness vs verbosity |
| Response latency | Impulsiveness vs deliberation |
### Trait Correlations (meta-analytic)
- **Extraversion**: more posts, more friends, more photos, more group activity
- **Neuroticism**: more self-disclosure, more passive consumption, more late-night posting
- **Agreeableness**: fewer swear words, more positive emotion, more supportive replies
- **Conscientiousness**: more regular posting patterns, more task-oriented content
- **Openness**: more diverse topics, more original content, larger networks
## Putting It All Together: The Deep Dossier
At high fidelity, compile a multi-layer profile:
```
PSYCHOMETRIC PROFILE: @handle
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Big Five: O[HIGH] C[MED] E[HIGH] A[LOW] N[LOW]
Evidence: {real quotes showing each trait}
Moral Foundations: Care★★ Fair★★★ Loyal★ Auth★ Sanct★ Liberty★★★
Evidence: {what they get angry/excited about}
Values: Self-Direction dominant, Achievement secondary
Evidence: {how they justify their positions}
Cognitive Style: HIGH integrative complexity
Evidence: {hedging patterns, nuanced takes, sentence complexity}
Dominant Frame: Attribution of Responsibility
Evidence: {they consistently focus on who's to blame}
Behavioral: Night owl, reply-heavy, low emoji, threads > one-shots
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
This multi-layer profile makes predictions much more nuanced than
Big Five alone. It tells you not just WHAT someone will say but
WHY they'll say it and HOW they'll frame it.
@@ -0,0 +1,170 @@
# GEPA Evolution — Automated Self-Improvement via hermes-agent-self-evolution
## What This Is
The hermes-agent-self-evolution repo (NousResearch/hermes-agent-self-evolution)
uses DSPy + GEPA (Genetic-Pareto Prompt Evolution) to automatically evolve
Hermes Agent skills. GEPA is an ICLR 2026 Oral paper — it reads EXECUTION
TRACES to understand WHY things fail, then proposes targeted mutations.
This means: we can point GEPA at the worldsim skill and automatically evolve
every component — simulation instructions, anti-slop rules, star thread
methodology, mechanical verification checklist, dossier templates — using
our own simulation outputs scored against real data as the eval signal.
The recursive self-improvement pipeline we built manually (log failures →
promote patterns → update rules) can be AUTOMATED via GEPA.
## How It Applies to WorldSim
### What GEPA Evolves (text, not weights)
GEPA evolves the TEXT of prompts and instructions. For worldsim, that means:
| Target | What Gets Evolved | Eval Signal |
|--------|------------------|-------------|
| SKILL.md | Immersion protocol, pipeline instructions | Simulation quality scores |
| star-thread.md | Methodology for finding star threads | Thread-to-voice accuracy |
| anti-slop.md | Slop word lists, structural patterns | Slop detection recall/precision |
| simulation-engine.md | Platform formats, conversation dynamics | Voice fidelity scores |
| adversarial-refinement.md | Mechanical check thresholds, GAN loop | Pre vs post refinement delta |
| prediction-engine.md | Forecasting methodology | Prediction Brier scores |
| dossier template | Profile structure and fields | Profile quality scores |
### The Eval Dataset
Built from worldsim's own outputs + real data:
1. **Voice fidelity pairs**: (simulated post, real post from same person) →
LLM-as-judge scores similarity 0-1
2. **Mechanical check logs**: what did the checks catch? what slipped through?
3. **Prediction accuracy**: tracked predictions scored against reality
4. **Held-out tests**: predicted tweets vs actual tweets
5. **Turing test results**: could the discriminator tell real from fake?
6. **User corrections**: any time the user catches something the system missed
(like the emoji fabrication incident — that's the richest signal)
### The GEPA Loop for WorldSim
```
1. RUN worldsim simulation (creates execution traces)
2. SCORE outputs against real data (voice, position, mechanical)
3. LOG traces + scores + user feedback to eval dataset
4. GEPA EVOLVES the skill component that had lowest scores
- Reads traces to understand WHY it scored low
- Proposes mutation to that specific reference file
- Tests mutation against held-out eval data
- If improved: create PR, human reviews
5. REPEAT — each cycle makes the skill better
```
### Concrete Example
GEPA discovers from traces that simulated conversations always have
symmetric turn-taking (4/4/4). It reads the mechanical check log that
caught this in 3 of the last 5 simulations. It reads the current
simulation-engine.md and sees the conversation architecture section.
It proposes a mutation:
OLD: "Opening Moves (1-3 posts) → Development (4-8 posts) → Peak → Resolution"
NEW: "Opening: most impulsive person posts. Others join ASYMMETRICALLY — one person
gets 40-50% of turns, one gets 15-20%, others fill the rest. The ratio should
match their real reply-to-original ratios from the dossier."
This mutation gets tested against the next 5 simulations. If symmetry
violations drop and voice scores don't decrease, it gets merged.
## Setup
```bash
# Clone the evolution repo
git clone https://github.com/NousResearch/hermes-agent-self-evolution.git
cd hermes-agent-self-evolution
pip install -e ".[dev]"
# Point at hermes-agent repo
export HERMES_AGENT_REPO=~/.hermes
# Evolve the worldsim skill specifically
python -m evolution.skills.evolve_skill \
--skill hermes-simulator \
--iterations 10 \
--eval-source sessiondb
```
## What Makes This Different From Manual Self-Improvement
The manual pipeline (references/recursive-self-improvement.md) requires the
agent to notice its own failures and write rules. This has two problems:
1. The agent shares weights with the generator — it's biased toward
approving its own output (the emoji incident proved this)
2. Promoting patterns to rules is slow and requires 3+ occurrences
GEPA solves both:
1. The eval signal comes from EXTERNAL data (real posts, user corrections,
mechanical checks) — not the agent's self-assessment
2. Evolution happens per-iteration, not per-3-failures
3. Mutations are tested against held-out data before merging
4. The Pareto frontier maintains diversity — different strategies for
different types of people/conversations
## Integration Points
### Eval Dataset Builder
Mine rehoboam DB for training data:
- simulation_logs table → execution traces
- prediction_scores table → accuracy data
- audit_log table → mechanical check results
- user correction events → highest-value signal
### Fitness Function for WorldSim
```python
def worldsim_fitness(simulation_output, real_data):
scores = {}
# Voice fidelity: embed real + simulated, cosine similarity
scores["voice"] = embed_and_compare(simulation_output, real_data.tweets)
# Mechanical pass rate: what % of checks passed without fixes
scores["mechanical"] = mechanical_check_pass_rate(simulation_output)
# Slop score: count of slop words/patterns detected
scores["anti_slop"] = 1.0 - (slop_count / total_words)
# Structure: turn asymmetry, conversation naturalness
scores["structure"] = naturalness_score(simulation_output)
# Textual feedback for GEPA's reflective mutation
feedback = generate_textual_feedback(scores, simulation_output, real_data)
return aggregate_score(scores), feedback
```
### The Key Insight: Textual Feedback
GEPA's superpower is that it doesn't just get a scalar score — it gets
TEXTUAL FEEDBACK explaining what went wrong. Our mechanical verification
system already produces this:
"@nosilverv avg 33.2 words vs real 15.6 (113% deviation) — SHORTEN"
"Parallel antithesis detected: 'The most X... The most Y...' — STRIP"
"Emoji rate 0% simulated but 10% real — OK (within tolerance)"
This text goes directly into GEPA's reflective mutation pipeline. It reads
these messages and proposes changes to the skill instructions that would
prevent these specific failures in future simulations.
## Evolution Targets by Priority
1. **simulation-engine.md** — highest impact on output quality
2. **anti-slop.md** — directly measurable, highest precision eval
3. **star-thread.md** — hardest to evaluate but most impactful on voice
4. **adversarial-refinement.md** — meta: improving the improvement system
5. **SKILL.md pipeline instructions** — orchestration optimization
6. **dossier template** — structure optimization
7. **prediction-engine.md** — measurable via Brier scores
## The Virtuous Cycle
```
More simulations → more eval data → better GEPA mutations
→ better skill instructions → better simulations → more eval data → ...
```
This is the endgame: the worldsim skill evolves itself through use.
Every simulation makes the next one better, not just through logged
rules, but through automated evolutionary optimization of the
instructions themselves. The system doesn't just learn WHAT went wrong —
it rewrites its own code to prevent it.
@@ -0,0 +1,262 @@
# Knowledge Archive — Per-Person Source Library + Expert Synthesis
## The Problem With Profiles
A profile is a SNAPSHOT. It says "this person believes X" but doesn't
show you WHERE they said it, WHEN, in WHAT context, or HOW their
thinking evolved. You can't cite a profile. You can't trace a claim
back to a source. And when you're simulating a conversation about
topic Z, the profile gives you everything about the person equally
weighted — their views on AI and their views on cooking and their
views on politics all crammed into the same context window.
## The Archive
For every person the system touches, build a LIBRARY:
```
~/.hermes/rehoboam/archives/{handle}/
├── index.json ← master index: all entries, metadata, embeddings
├── sources/
│ ├── x_tweets.jsonl ← every tweet pulled, with ID, timestamp, URL, metrics
│ ├── x_replies.jsonl ← their replies (different voice register)
│ ├── bluesky_posts.jsonl ← bluesky posts
│ ├── blog_posts.jsonl ← full text of blog posts with URLs
│ ├── podcast_quotes.jsonl ← attributed quotes from transcripts
│ ├── interviews.jsonl ← quotes from news articles/interviews
│ ├── reddit_comments.jsonl
│ ├── github_comments.jsonl
│ ├── goodreads_reviews.jsonl
│ ├── threads_posts.jsonl
│ └── other.jsonl ← anything else (HN, Quora, etc.)
├── topics/
│ ├── ai_safety.jsonl ← auto-clustered by topic
│ ├── open_source.jsonl
│ ├── consciousness.jsonl
│ └── ...
└── embeddings/
└── all_embeddings.npy ← sentence-transformer vectors for semantic search
```
### Entry Format (every entry in every source file)
```json
{
"id": "unique_id",
"handle": "teknium",
"platform": "x",
"type": "tweet|reply|blog|podcast|interview|comment|review",
"text": "the actual text they said",
"url": "https://x.com/Teknium/status/1234567890",
"timestamp": "2026-04-05T21:40:48Z",
"context": {
"replying_to": "@otheruser's tweet about X",
"thread_position": 3,
"topic": "open source AI",
"source_title": "Lex Fridman Podcast #412"
},
"metrics": {
"likes": 234,
"retweets": 45,
"replies": 12
},
"topics": ["open_source", "ai_models", "hermes"],
"embedding_id": 42
}
```
Every entry has a URL. Everything is traceable. Nothing is paraphrased
without the original alongside it.
## Collection Pipeline
When `worldsim> profile @handle` or `worldsim> archive @handle` runs:
### Step 1: Pull Everything
Use every verified access method to collect raw materials:
- X API: get max tweets (paginate with next_token to get hundreds)
- nitter.cz: timeline content
- ThreadReaderApp: historical threads
- Bluesky: full post history
- GitHub: issue comments, PR reviews, gists, README
- Reddit: comment history
- Blog/Substack: full posts (web_extract)
- Podcast transcripts: attributed quotes
- Interviews: quotes with attribution
- Goodreads: reviews
- Medium: RSS feed full text
### Step 2: Deduplicate
Same content appears across platforms (cross-posted tweets, syndicated
blog posts). Deduplicate by content similarity, keep the richest version
(the one with most metadata/context).
### Step 3: Topic Cluster
Run lightweight topic classification on each entry:
- Use the LLM or a simple keyword matcher to assign 1-3 topic tags
- Cluster into topic files for fast retrieval
- Topics are dynamic — new topics emerge from the data
### Step 4: Embed
Generate sentence-transformer embeddings for every entry.
Store in numpy array for fast cosine similarity search.
This enables semantic retrieval: "find everything @handle said about
consciousness" even if they never used the word "consciousness."
### Step 5: Index
Build the master index.json with entry count, topic distribution,
timestamp range, platform coverage, and quality metrics.
## Context-Aware Retrieval
This is the key. The archive might have 500 entries for a person.
The context window can hold maybe 30-50 of them alongside all the
other simulation context. You MUST retrieve selectively.
### For Simulation
When simulating @handle talking about topic X:
```
1. Semantic search: embed the current conversation context
2. Retrieve top 10-15 entries by cosine similarity to context
3. Also retrieve: 5 highest-engagement entries (their "greatest hits")
4. Also retrieve: 3 most recent entries (freshness)
5. Also retrieve: 2 entries that CONTRADICT the expected position
(prevents confirmation bias in the simulation)
6. Deduplicate. Cap at 25-30 entries total.
7. These become the "voice anchors" for generation.
```
The simulation draws from SPECIFIC REAL QUOTES relevant to the current
conversation. Not a generic profile. Not everything they've ever said.
The 25 most relevant things they've said about THIS topic.
### For Expert Synthesis
When the user asks "who are the best minds on X and what have they said?":
```
1. Search ALL archived people's entries for topic X
2. Rank by: entry quality × person expertise × relevance to query
3. Return a synthesis with CITATIONS:
On the topic of AI consciousness:
@repligate argues that LLMs exhibit "simulacra of consciousness"
rather than consciousness itself, distinguishing between the
model's behavior and its substrate:
> "the question isn't whether GPT is conscious but whether the
> character it's simulating is conscious within the fiction"
— tweet, 2025-03-15 (2.4K likes)
https://x.com/repligate/status/...
@nickcammarata approaches it from a meditation/first-person
perspective, noting parallels between introspective practice
and interpretability:
> "observation changes the system being observed, in meditation
> and in interp"
— tweet, 2026-04-05 (2.9K likes)
https://x.com/nickcammarata/status/...
@tszzl is skeptical of the framing entirely:
> "consciousness discourse is philosophy cosplaying as engineering"
— tweet, 2025-11-22 (5.1K likes)
https://x.com/tszzl/status/...
```
Every claim attributed. Every quote sourced. Every link clickable.
### For Grounding Predictions
When predicting what @handle would say about event Y:
```
1. Retrieve all archive entries related to Y or adjacent topics
2. Identify their PATTERN of response to similar events
3. Ground the prediction in specific past statements:
PREDICTION: @handle would likely frame event Y through the lens
of [topic Z], based on:
- tweet [url]: "quote about Z" (2025-06-15)
- blog post [url]: "longer quote about Z" (2025-09-20)
- podcast [url]: "verbal quote about Z" (2026-01-10)
CONFIDENCE: 78% (3 consistent sources over 7 months)
```
## Incremental Updates
The archive grows over time. Each time the person is profiled:
1. Pull new content since last archive timestamp
2. Append to source files
3. Re-embed new entries only
4. Update topic clusters
5. Update index
Don't rebuild from scratch. Append and re-index.
## Expert Table
When you have 20+ archived people, build an expert table:
```
worldsim> experts "open source AI"
EXPERT TABLE: open source AI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@Teknium | 47 entries | voice: builder/practitioner
"we can prove that open approaches build better, more
trustworthy systems" — tweet, 2026-04-05
Latest: 2 hours ago | Stance: STRONG ADVOCATE
@repligate | 12 entries | voice: philosophical/theoretical
"open weights = accountability. you can't audit a black box"
— tweet, 2025-11-30
Latest: 3 days ago | Stance: ADVOCATE (principled)
@eigenrobot | 8 entries | voice: statistical/contrarian
"the open source premium is largely downstream of selection
effects in who contributes" — tweet, 2025-08-14
Latest: 1 week ago | Stance: SKEPTICAL OF FRAMING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3 experts found | 67 total entries | synthesize? (y/n)
```
The table shows: who knows about this, what they've said, how recently,
and what their stance is. All grounded in archived quotes with sources.
## Integration With Simulation
When the star thread + dossier + archive work together:
```
STAR THREAD: drives the core generation (what they're DOING)
DOSSIER: provides constraints (psychometrics, voice metrics, baselines)
ARCHIVE: provides GROUNDING (specific real quotes for this context)
MECHANICAL CHECKS: verifies surface features (emoji, length, slop)
```
The archive prevents the simulation from drifting into generic territory.
Instead of "this person would probably say something about open source,"
it's "this person said THIS SPECIFIC THING about open source 3 weeks ago,
and their simulation should be consistent with that while also being fresh."
## The Overfitting Problem
"Without overfitting to a particular material the new context doesn't call for."
The retrieval system MUST be selective. If someone said 47 things about
open source AI, and the current conversation is about AI regulation,
don't dump all 47 open source quotes into context. Maybe 3 are relevant
because they connect open source to regulation. Retrieve THOSE 3.
The cosine similarity search handles this naturally — it matches the
CURRENT conversation context against the archive and returns what's
actually relevant, not everything tagged with a nearby topic.
The anti-overfitting checklist:
- Never load more than 25-30 archive entries per person into context
- Weight by relevance to CURRENT conversation, not by general importance
- Include at least 2 entries that contradict the expected position
- Include at least 3 recent entries regardless of topic relevance (freshness)
- If the conversation shifts topic mid-simulation, RE-RETRIEVE for new context
- The archive is a LIBRARY you consult, not a script you follow
@@ -0,0 +1,321 @@
# Mass Behavior Modeling — Communities, Clusters, Cascades
Understanding individual behavior requires understanding the social
ecosystem they exist in. This reference covers the macro layer:
community detection, influence networks, audience modeling, and
predicting how groups respond to events.
## Why This Matters For Simulation
Individual prediction accuracy: ~56-60%
Individual-in-context prediction: significantly higher
A person's behavior is constrained by their community. Knowing WHICH
community they belong to, WHO influences them, and WHAT information
ecosystem they're in makes individual predictions much sharper.
Lewin's equation: B = f(P, E). This reference is about the E.
## The Ecosystem Stack
```
Layer 5: AUDIENCE REACTION — How would this person's audience respond?
Layer 4: STANCE & SENTIMENT — What positions do clusters hold?
Layer 3: INFLUENCE NETWORKS — Who spreads ideas to whom?
Layer 2: COMMUNITY CLUSTERS — Who groups together?
Layer 1: SOCIAL GRAPH — Who follows/interacts with whom?
```
## Layer 1: Social Graph Construction
### Data Sources (by accessibility)
| Source | Access | Quality | Tools |
|--------|--------|---------|-------|
| Bluesky AT Protocol | FREE, open, no auth | Excellent | atproto (pip) |
| X/Twitter API | Bearer token, limited | Good but restricted | curl, tweepy |
| Reddit | API with limits | Good for comments | PRAW (pip) |
| GitHub | Free API | Great for tech people | PyGithub (pip) |
| Web scraping | Fragile, TOS issues | Variable | Last resort |
### Bluesky: The Open Gold Mine
```python
# pip install atproto
from atproto import Client
client = Client()
# No auth needed for public data
# Get follower graph
followers = client.get_followers(actor="handle.bsky.social")
following = client.get_follows(actor="handle.bsky.social")
# Real-time firehose (no auth!)
# wss://jetstream1.us-east.bsky.network/subscribe
```
### Graph Types
- **Follow graph**: who follows whom (directed, static-ish)
- **Interaction graph**: who replies to / retweets whom (directed, dynamic)
- **Mention graph**: who mentions whom (directed, weighted by frequency)
- **Co-engagement graph**: who engages with the same content (undirected)
Interaction graphs are more informative than follow graphs for predicting
actual behavioral alignment.
### Tools
```
pip install networkx python-igraph
```
NetworkX for prototyping (<100K nodes), igraph for production (millions).
## Layer 2: Community Detection
### Algorithms (ranked by quality)
| Algorithm | Quality | Speed | Notes |
|-----------|---------|-------|-------|
| Leiden | Best | Fast | Guarantees connected communities |
| Louvain | Good | Fastest | Can produce disconnected communities |
| Infomap | Excellent | Medium | Based on information theory |
| Label Propagation | Decent | Very fast | Non-deterministic |
### The Meta-Library: CDLib
```
pip install cdlib
```
Wraps 50+ community detection algorithms in a unified API.
Works on top of networkx/igraph. Highly recommended.
```python
import cdlib
from cdlib import algorithms
import networkx as nx
G = nx.karate_club_graph()
communities = algorithms.leiden(G)
# Also: louvain, infomap, label_propagation, angel, demon, etc.
```
### What Communities Tell Us
Each community in a social graph typically shares:
- Ideological orientation
- Topic interests
- Information sources
- Language patterns and in-group vocabulary
- Reaction patterns to events
Knowing which community someone belongs to immediately constrains
predictions about their likely positions and reactions.
## Layer 3: Influence Networks
### Key Insight (Zhou et al., National Science Review 2024)
Network centrality alone is INSUFFICIENT for predicting influence.
Must combine structural position with behavioral features:
- Posting frequency
- Historical content virality
- Response rate / engagement ratio
- Content originality (original vs repost ratio)
### Centrality Measures
```python
import networkx as nx
G = nx.DiGraph() # directed social graph
# Who has the most connections?
degree = nx.degree_centrality(G)
# Who bridges different communities?
betweenness = nx.betweenness_centrality(G)
# Who's connected to other well-connected people?
eigenvector = nx.eigenvector_centrality(G)
# Adapted from web — directed influence flow
pagerank = nx.pagerank(G)
```
### Superspreader Identification (DeVerna et al., PLOS ONE 2024)
Superspreaders of content fall into three categories:
1. **Pundits**: large following, high authority, original content
2. **Media outlets**: institutional accounts, news organizations
3. **Affiliated personal accounts**: connected to pundits/outlets
For simulation: knowing who the superspreaders are in a person's
network tells you what information they're likely exposed to.
### Information Cascade Modeling
```
pip install ndlib # Network Diffusion Library
```
NDlib models how information spreads through networks:
- Independent Cascade Model
- Linear Threshold Model
- SIR/SIS epidemiological models adapted for info spread
- Voter Model (opinion dynamics)
- Sznajd Model (social influence)
## Layer 4: Stance & Sentiment Analysis
### Ready-To-Use Models (HuggingFace)
**Tweet Sentiment** (most reliable):
```
cardiffnlp/twitter-roberta-base-sentiment-latest
# Labels: positive / negative / neutral
```
**Political Stance**:
```
kornosk/bert-election2020-twitter-stance-biden-KE-MLM
kornosk/bert-election2020-twitter-stance-trump-KE-MLM
launch/POLITICS # left / center / right
```
**All-in-One Tweet NLP**:
```
pip install tweetnlp
# Sentiment, emotion, hate speech, NER, topic classification
```
### Topic-Level Stance Tracking
Combine BERTopic (dynamic topic modeling) with stance classifiers:
1. Cluster posts into topics over time windows
2. Classify stance per topic per community
3. Track stance shifts over time
4. Detect divergence between communities on emerging topics
### PRISM Framework (ACL 2025)
First framework for interpretable political bias embeddings.
Two-stage: mine bias indicators → cross-encoder assigns structured scores.
```
github.com/dukesun99/ACL-PRISM
```
## Layer 5: Audience Modeling & Crowd Prediction
### The Frontier: Predicting How Groups React
Key papers and findings:
**CReAM (WWW 2024)**: Predicts which of two posts gets more engagement.
Uses LLM-generated features + FLANG-RoBERTa cross-encoder.
Demonstrates crowd reaction IS predictable from content alone.
**PopSim (Dec 2025)**: LLM multi-agent social network sandbox.
Simulates content propagation dynamics using "Social Mean Field"
for individual-population interaction. Reduces prediction error 8.82%.
**Conditioned Comment Prediction (EACL 2026)**:
KEY FINDING: behavioral traces (past posts) are BETTER than
descriptive personas for conditioning LLMs to predict user behavior.
This validates our OSINT approach: real data > personality labels.
**DEBATE Benchmark (Oct 2025)**:
WARNING: LLM agents converge opinions TOO QUICKLY vs real humans.
SFT + DPO helps but gap remains. Real communities maintain
disagreement longer than simulated ones.
**Distributional vs Individual Prediction (PMC 2025)**:
Group-level predictions are more reliable than individual ones.
Predicting "65% of this community will react negatively" is more
accurate than predicting "this specific person will react negatively."
### Application to Simulation
When simulating @person talking about event X, consider:
1. What community does @person belong to?
2. How is that community reacting to X? (distributional prediction)
3. Where does @person sit within that community? (conformist vs contrarian)
4. Who influences @person? What are THEY saying?
5. How does @person's audience react to their take? (engagement prediction)
This context makes individual predictions sharper.
## Echo Chamber & Filter Bubble Detection
### Technique
1. Build interaction graph
2. Run Leiden community detection
3. For each community, aggregate stance on key issues
4. Measure ideological homogeneity within communities
5. Compare cross-community vs within-community content similarity
6. High within + low cross = echo chamber
### Tools
```
github.com/mminici/Echo-Chamber-Detection # Cascade-based, CIKM 2022
# Includes Brexit and VaxNoVax datasets
```
### What It Tells Us
Knowing someone's echo chamber tells you:
- What information they're exposed to
- What they're NOT exposed to
- How extreme their positions might be (isolation → radicalization)
- Whether they'll encounter pushback or only agreement
- How they'll react to information from outside their bubble
## User Embeddings: "Find People Like @person"
### Strategy
1. Embed each user's recent N posts with sentence-transformers
2. Average embeddings → user vector
3. Use FAISS for similarity search
4. Cluster users with HDBSCAN in embedding space
### Best Models for Social Media Text
```
# General purpose (good baseline)
sentence-transformers/all-mpnet-base-v2
# Tweet-specific (better domain fit)
cardiffnlp/twitter-roberta-base
vinai/bertweet-base # pretrained on 850M tweets
```
### Graph + Text Hybrid Embeddings
```
pip install karateclub
```
KarateClub provides Node2Vec, DeepWalk, Graph2Vec — embed users
based on graph position. Combine with text embeddings for hybrid
vectors that capture BOTH what someone says AND where they sit
in the social network.
## Practical Application to Simulation
### For Individual Simulation (what we already do)
Add ecosystem context to each dossier:
- Which community cluster they belong to
- Who their top influencers are (who do they retweet/amplify most)
- What echo chamber are they in (information environment)
- How does their community view the simulation topic
### For Audience Simulation (new capability)
When user asks "what would @person's audience say":
1. Identify @person's follower community
2. Sample representative voices from that community
3. Model the DISTRIBUTION of responses, not just one response
4. Include: cheerleaders, critics, joke-makers, lurkers
5. Weight by typical engagement patterns
### For Cascade Prediction (new capability)
When user asks "how would this take spread":
1. Model the initial tweet and its immediate network
2. Predict which nodes amplify (based on stance alignment + influence)
3. Estimate reach and engagement range
4. Predict quote-tweet ratio (agreement vs dunking)
## Recommended Minimal Stack
```bash
pip install networkx python-igraph leidenalg cdlib karateclub
pip install sentence-transformers transformers tweetnlp
pip install ndlib faiss-cpu hdbscan atproto
```
This gives you: graph construction, community detection, user embeddings,
stance/sentiment analysis, diffusion simulation, similarity search,
clustering, and Bluesky data access. All open source, all pip-installable.
@@ -0,0 +1,370 @@
# OSINT Pipeline — Deep Intelligence Gathering
Full-spectrum open source intelligence for building personality models.
This goes beyond social media posts into visual identity, cross-platform
footprints, and behavioral analysis.
## Tool Arsenal
| Tool | Use Case | Strength |
|------|----------|----------|
| `web_search` | Find anything, initial discovery | Fast, broad, indexed content |
| `web_extract` | Pull full page content | Blogs, articles, profiles, PDFs |
| `browser_navigate` + `browser_snapshot` | View live pages | Dynamic content, login walls |
| `browser_vision` | Analyze what a page looks like | Layouts, visual identity, screenshots |
| `vision_analyze` | Analyze any image by URL/path | Profile pics, post images, aesthetics |
| `browser_get_images` | List all images on a page | Find images to feed to vision_analyze |
| Yandex reverse image search | Find where an image appears | Identity verification, alt accounts |
| `x-cli` (if available) | Direct Twitter API | Timelines, search, metadata |
## Instagram Intelligence
Instagram is CRITICAL for personality modeling — it reveals:
- Visual identity and aesthetic preferences
- Real-life social circles (tagged people, group photos)
- Lifestyle signals (travel, food, hobbies, pets)
- Caption voice (often different from Twitter voice)
- Story highlights (curated self-image)
- Bio links (cross-platform connections)
### Viewing Instagram Profiles (VERIFIED APRIL 2026)
**METHOD 1 — Instagram Private Web API (BEST, returns full JSON)**
```bash
curl -s -H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)' \
-H 'x-ig-app-id: 936619743392459' \
'https://i.instagram.com/api/v1/users/web_profile_info/?username={handle}'
```
Returns ~500KB of JSON: full profile + last 12 posts with captions, likes,
comments, CDN image URLs, timestamps. No auth needed.
**METHOD 2 — Instagram oEmbed API (for individual posts)**
```bash
curl -s 'https://www.instagram.com/api/v1/oembed/?url=https://www.instagram.com/p/{SHORTCODE}/'
```
Returns: caption text, author_name, thumbnail URL. No auth.
**METHOD 3 — Pixwox via web_extract (profile viewer)**
```python
web_extract(["https://pixwox.com/profile/{username}"])
```
Returns 12+ recent posts with captions, engagement stats. Cloudflare blocks
curl but web_extract bypasses it.
**METHOD 4 — SocialBlade via web_extract (analytics)**
```python
web_extract(["https://socialblade.com/instagram/user/{handle}"])
```
Returns follower count, engagement rate, 14-day tracking.
**METHOD 5 — CDN direct download (images from API responses)**
Image URLs from API responses (scontent-*.cdninstagram.com) download
directly with no auth. Feed them to vision_analyze for visual profiling.
**METHOD 6 — Google indexed content**
```
web_search("site:instagram.com {username}")
```
Returns bio text, follower count, recent post captions from search snippets.
**WHAT DOESN'T WORK:** direct web_extract on instagram.com, ?__a=1 trick,
graph.instagram.com (needs OAuth), imginn/picuki/dumpoir/gramhir (403)
### Instagram Discovery (finding someone's handle)
```
web_search("{real_name} instagram")
web_search("{twitter_handle} instagram account")
web_search("site:instagram.com {real_name}")
# Check their Twitter/X bio for IG links
# Check their personal website for social links
# Check Linktree / bio.link pages
```
### Extracting Signal from Instagram
**Profile Picture**: Reveals self-presentation style
- Professional headshot vs casual vs meme/avatar
- Analyze with vision_analyze for clothing, setting, expression
**Bio Text**: Compressed self-identity
- Role/title claims
- Emoji usage patterns
- Link destinations
- Location claims
**Post Grid**: Visual identity fingerprint
- Color palette tendencies
- Content categories (food/travel/tech/selfies/memes)
- Posting frequency
- Professional vs personal ratio
**Captions**: Voice sample different from Twitter
- Usually longer, more personal
- Hashtag usage patterns
- Emoji patterns
- Tone (inspirational vs casual vs funny)
**Tagged Photos**: Real social graph
- Who they hang out with IRL
- Events they attend
- Social circles outside tech/AI
## Visual Identity Analysis
Use vision tools to analyze HOW someone presents visually:
### Profile Pictures Across Platforms
```
# Collect profile pics from multiple platforms
# Twitter, Instagram, LinkedIn, GitHub, Discord
# Analyze each
vision_analyze(image_url="{pic_url}",
question="Describe this profile picture in detail: person's appearance, clothing style, setting, expression, professional vs casual, any notable elements")
# Cross-reference: do they use the same pic everywhere? Different personas?
```
### Reverse Image Search (Yandex Pipeline)
From memory — Google Lens blocks Browserbase IPs, use Yandex:
```
# For images behind auth/CDN, upload to catbox first
terminal("curl -F 'reqtype=fileupload' -F 'fileToUpload=@{local_path}' https://catbox.moe/user/api.php")
# Then Yandex reverse image search
browser_navigate("https://yandex.com/images/search?rpt=imageview&url={encoded_public_url}")
# Or via web_extract (slower but automatable)
web_extract(["https://yandex.com/images/search?rpt=imageview&url={encoded_url}"])
```
Yandex provides:
- Similar images (find the same person elsewhere)
- Site matches (where this image appears)
- OCR text extraction (text in images)
- Image tags (what's in the image)
- Knowledge panels (identified entities)
### Screenshot Analysis
When you can see a page but can't extract text:
```
browser_vision(question="Read all text on this page. List usernames, post content, dates, engagement numbers")
browser_vision(annotate=true, question="What interactive elements are on this page?")
```
## LinkedIn Intelligence
**STATUS: BLOCKED for automated access** (tested April 2026).
web_extract returns "Website Not Supported". Direct browsing triggers auth walls.
**Workarounds:**
```
# LinkedIn content IS indexed by search engines
web_search("{real_name} linkedin {company}")
web_search("site:linkedin.com/in {name}")
# These return snippets with headline, role, company — useful even without full profile
# Google sometimes caches LinkedIn profiles
web_search("{name} site:linkedin.com headline")
```
**METHOD 1 — Google indexed snippets (always works)**
```
web_search("site:linkedin.com/in {name} {company}")
```
Returns: name, headline, company, location, connection count, bio snippet.
**METHOD 2 — Crunchbase (EXCELLENT for founders/execs)**
```python
web_extract(["https://www.crunchbase.com/person/{slug}"])
```
Returns: full career history, education, investments, board positions,
social links. Best source for professional identity of startup people.
**METHOD 3 — Corporate press pages**
```
web_search("{person} {company} site:{company}.com bio OR press")
```
Official bios from company newsrooms. High quality, curated but factual.
**METHOD 4 — Third-party aggregators**
- RocketReach, SignalHire — job title + company from web_search snippets
- rootdata.com — good for crypto/AI people
- Crunchbase — best all-round for tech executives
**METHOD 5 — Paid LinkedIn API wrappers** (if budget allows)
- LinkdAPI, Proxycurl: $0.07-0.15 per profile, full structured data
- No OAuth needed, just API key
LinkedIn reveals (from combined methods):
- Career trajectory (Crunchbase full history)
- Current role and headline (search snippets)
- Education (Crunchbase or search snippets)
- Professional self-presentation (company bio pages)
- Investment/board activity (Crunchbase)
## Podcast Transcripts (HIGHEST VALUE for voice profiling)
Podcast interviews are THE gold mine for personality modeling. Hours of
unscripted speech, natural conversation, real personality showing through.
**Discovery:**
```
web_search("{name} podcast transcript interview")
web_search("{name} lex fridman OR tyler cowen OR joe rogan OR dwarkesh")
```
**Extraction — verified working transcript sources:**
```python
# Lex Fridman (full verbatim transcripts)
web_extract(["https://lexfridman.com/EPISODE_URL/transcript"])
# Conversations with Tyler (Tyler Cowen — full transcripts)
web_extract(["https://conversationswithtyler.com/episodes/..."])
# TED Talks transcripts
web_extract(["https://www.ted.com/talks/.../transcript"])
# Sequoia Capital podcast
web_extract(["https://www.sequoiacap.com/podcast/..."])
```
Podcast transcripts reveal:
- Natural speech patterns (filler words, pacing, sentence structure)
- Unguarded opinions (less curated than tweets)
- How they respond to pushback (interviewer challenges)
- Humor style in conversation (different from written humor)
- Depth of knowledge on specific topics
- Personality under pressure
## YouTube / Video Intelligence
```
web_search("{name} youtube talk keynote interview")
web_search("{name} podcast appearance")
```
web_extract on YouTube pages returns rich summaries with attributed quotes.
Use youtube-content skill for full transcripts if available.
## Personal Blogs & Substacks (HIGH VALUE)
Personal writing is curated self-expression — how someone WANTS to be
seen intellectually. Very different signal from social media.
```
web_search("{name} blog substack essay")
# Extract full posts
web_extract(["https://{blog-url}/"])
# Wayback Machine works for archived blog posts
web_extract(["https://web.archive.org/web/2024/{blog-url}"])
```
## GitHub Intelligence
For technical people:
```
web_search("site:github.com {handle}")
web_extract(["https://github.com/{handle}"])
# Issue comments reveal communication style under technical pressure
web_search("site:github.com {handle} issue comment")
# README style reveals documentation personality
# Commit messages reveal terseness vs verbosity
```
## General Web Footprint
```
# Personal website / blog
web_search("{name} personal website blog about")
# Conference talks / speaker bios
web_search("{name} speaker conference talk bio")
# News mentions
web_search("{name} {company} news interview profile")
# Academic papers (for researchers)
web_search("{name} arxiv paper author")
web_search("site:scholar.google.com {name}")
# Podcast appearances
web_search("{name} podcast guest appearance")
# Forum posts (HN, specific communities)
web_search("site:news.ycombinator.com {handle} OR {name}")
```
## Cross-Platform Identity Resolution
### Handle Mapping Strategy
1. Start from known handle (usually Twitter)
2. Check bio links — most people link to other platforms
3. Search "{known_handle} {platform}" for each platform
4. Check personal website for social links
5. Reverse image search profile pic to find matching accounts
6. Search unique phrases they use across platforms
### Identity Verification
When you find a potential match on another platform:
- Same profile picture? (reverse image search)
- Same bio keywords?
- Same name/handle pattern?
- Cross-references (do they mention each other?)
- Writing style match?
## Search Space Narrowing
### The Jiggle Technique
When broad searches return noise, narrow progressively:
1. **Start broad**: `"{name}" AI`
2. **Add role**: `"{name}" {company} {role}`
3. **Add context**: `"{name}" {company} {specific_project_or_topic}`
4. **Add platform**: `site:{platform} "{name}" {context}`
5. **Add time**: `"{name}" {topic} 2025 OR 2026`
6. **Quote unique phrases**: if you found a distinctive phrase they use, search for that exact phrase to find more of their content
### Disambiguation
Common names need extra signals:
- Add their company/org
- Add their specific domain (AI, crypto, etc.)
- Use their unique handle as anchor
- Search for combinations of their known associates
- Use image search to verify you have the right person
### Signal vs Noise Heuristics
- **High signal**: direct quotes, interview transcripts, personal blog posts, long-form content
- **Medium signal**: mentions in aggregator sites, conference bios, LinkedIn summaries
- **Low signal**: generic news mentions, third-party profiles, directory listings
- **Noise**: same-name different person, outdated info (>2 years), scraped/regurgitated content
## Confidence Calibration
After full OSINT sweep, rate data quality:
| Confidence | Data Available | Simulation Quality |
|-----------|---------------|-------------------|
| 95-100% | 50+ posts, longform, video, visual, cross-platform | Near-perfect voice replication |
| 80-94% | 20-50 posts, some longform, basic visual | Very good, occasional educated guesses |
| 60-79% | 10-20 posts, mostly short-form | Good general sense, some gaps |
| 40-59% | 5-10 posts, limited platforms | Broad strokes only, flag uncertainty |
| 20-39% | <5 posts, single platform | Sketch at best, heavy disclaimers |
| <20% | Almost nothing found | Decline to simulate, ask user for context |
## Privacy & Ethics Note
All research uses publicly available information only. We don't:
- Access private/locked accounts
- Bypass authentication
- Use leaked/hacked data
- Dox or expose private information
- Simulate in ways designed to deceive or impersonate
The goal is personality MODELING for creative simulation, grounded in
what people choose to share publicly.
@@ -0,0 +1,334 @@
# Prediction Engine — Forecasting What Someone Would Say/Do
Techniques for predicting behavior grounded in superforecasting methodology,
behavioral science, and SOTA LLM prediction research.
## Superforecasting Principles (Tetlock)
**Honest caveat**: Superforecasting methodology was developed for geopolitical and
world-event prediction, not personality simulation. That said, the THINKING TOOLS
are genuinely useful here — decomposition prevents lazy pattern-matching, base rates
fight overconfidence, and alternative hypotheses prevent single-track predictions.
What does NOT transfer cleanly: the calibration precision. When Tetlock says "70%
confident," that's backed by thousands of scored predictions. When we say "70%
confident" about what @someone would tweet, that's an educated estimate, not a
calibrated probability. Use the framework for its rigor, not its false precision.
Apply these thinking tools when making behavioral predictions:
### 1. Decomposition (Fermi-ize the Question)
Don't ask "What would @person say about X?"
Break it down:
- What is @person's known position on topics RELATED to X?
- What are their values/priorities that X touches on?
- What is their emotional register when discussing similar topics?
- Who are they likely responding to, and how does that change their tone?
- What platform are they on, and how does that shift their behavior?
### 2. Outside View First (Base Rates)
Before considering the specific person, ask:
- What would a TYPICAL person in their role/position say about X?
- What % of people in their ideological cluster hold position Y on X?
- What's the base rate for their type of response (agree/disagree/joke/ignore)?
### 3. Inside View Second (Case-Specific Adjustment)
Now adjust from the base rate using what you ACTUALLY KNOW about them:
- Specific past statements on this topic or related topics
- Known relationships with people/orgs involved
- Personal experiences that would shape their view
- Contrarian tendencies (do they predictably go against their cluster?)
### 4. Confidence Calibration
Express predictions with honest uncertainty. **These are rough buckets, not
calibrated probabilities. Don't pretend they're more precise than they are.**
- **90%+ confident**: They've literally said this before, just rephrased
- **70-89%**: Strong pattern match with known positions and voice
- **50-69%**: Reasonable inference but could go either way
- **30-49%**: Educated guess, limited data
- **<30%**: Basically guessing, flag it clearly
When reporting confidence, prefer plain language over fake precision:
"very likely" > "87% probability". The number implies a precision we don't have.
### 5. Consider Alternative Hypotheses
For every prediction, generate at least ONE plausible alternative:
- "They'd PROBABLY say X, but they might surprise with Y because Z"
- This prevents overconfident single-track predictions
## The Prediction Pipeline
### Step 1: Classify the Prediction Type
| Type | Description | Difficulty |
|------|-------------|-----------|
| **Position prediction** | What they believe about X | Easiest if data exists |
| **Reaction prediction** | How they'd respond to event Y | Medium |
| **Voice prediction** | How they'd phrase something | Medium-hard |
| **Behavior prediction** | What they'd DO (not just say) | Hardest |
| **Interaction prediction** | How they'd respond to specific person | Hard, depends on relationship data |
### Step 2: Evidence Gathering Protocol
For each prediction, gather evidence in this order:
1. **Direct evidence**: Have they addressed this exact topic before?
- Search: `"{handle}" "{topic}"` or `"{handle}" "{related_keyword}"`
- Weight: HIGHEST
2. **Analogical evidence**: Have they addressed something similar?
- Search: find positions on adjacent topics
- Weight: HIGH
3. **Value evidence**: What values/principles would apply?
- Infer from their stated beliefs and consistent positions
- Weight: MEDIUM
4. **Social evidence**: What do their peers/allies think?
- People tend to align with their social cluster (but not always)
- Weight: LOW-MEDIUM (higher for conformists, lower for contrarians)
5. **Demographic evidence**: What would someone in their position typically think?
- Base rate from role/industry/ideology
- Weight: LOWEST (only use as anchor, not conclusion)
### Step 2b: Contradiction Handling Protocol
When evidence conflicts (e.g., person said X in 2024 but Y in 2026):
1. **Check for genuine change**: Did they explicitly reverse position? Look for
"I used to think X but now..." or a clear pivot moment. If so, use the newer
position and note the evolution.
2. **Check for context-dependence**: Did they say X to audience A and Y to audience B?
This isn't necessarily dishonesty — people emphasize different facets for different
contexts. Note which context your simulation targets and use the matching register.
3. **Check for nuance collapse**: Maybe they said "X is mostly good with caveats"
and later "X has real problems" — these might not actually contradict. Look for
the synthesis position.
4. **When genuinely unresolvable**: Flag it explicitly. "Evidence conflicts on this
point — they've argued both sides at different times. Simulating {chosen position}
based on {reasoning}, but the alternative is plausible." Don't paper over the
contradiction with false confidence.
5. **Recency default**: When all else fails, weight more recent statements higher.
People change, and the most recent position is the best predictor of the next one.
### Step 3: Generate Prediction
Using the HumanLLM B = f(P, E) framework:
- **P (Person)**: Everything from the dossier — personality, values, voice
- **E (Environment)**: The specific context — platform, topic, who's asking,
what just happened, social dynamics in play
Generate the prediction by:
1. Setting the base rate (outside view)
2. Adjusting for personal specifics (inside view)
3. Filtering through their voice profile (how they'd phrase it)
4. Applying platform-specific behavior patterns
5. Calibrating confidence
## Memory Curation (The 30-50 Rule)
Research shows performance PEAKS at 30-50 memory entries then DECLINES.
For each person in a simulation, curate memories:
### What to Include (high signal)
- **Signature takes**: Their most characteristic/famous positions (5-10)
- **Voice samples**: Real quotes that capture their linguistic style (5-10)
- **Relationship data**: Known dynamics with other sim targets (3-5)
- **Recent context**: What they've been talking about lately (3-5)
- **Formative moments**: Career milestones, public pivots, viral moments (3-5)
- **Quirks & tells**: Catchphrases, humor style, pet peeves (3-5)
### What to Exclude (noise)
- Generic biographical facts that don't predict behavior
- Old positions they've clearly evolved past
- Trivial interactions that don't reveal personality
- Secondhand characterizations (what others say about them)
- Platform metadata (follower counts, join dates) unless directly relevant
### Memory Selection Heuristic
For each candidate memory entry, ask:
**"If I removed this, would the simulation noticeably degrade?"**
If no, cut it.
## Fighting LLM Defaults
Research shows LLMs have systematic biases in simulation. The fixes below need to be
CONCRETE — vague instructions like "be more like them" don't work. You need specific
prompting patterns that actually shift the output.
### Problem: Sycophancy & Over-Agreement
LLMs default to agreement and positivity.
**Fix**: Don't just note they're contrarian — structure it as a behavioral instruction
with evidence:
```
"In this conversation, {person} disagrees with {other_person} on {topic}. They are
noticeably more confrontational than the other speakers. They tend to respond to
consensus with skepticism and reframe debates on their own terms. Example from their
real posts: '{actual quote where they disagreed with something popular}'"
```
### Problem: Rigid/Polarized Strategies
LLMs tend to take extreme positions and hold them rigidly.
**Fix**: Provide specific nuance instructions:
```
"In this conversation, {person} holds a complex position on {topic}: they agree with
{point A} but push back on {point B}. They're the type to say 'yes, but...' rather
than 'no.' Real example of their nuance: '{quote showing them holding a both-and
position}'"
```
### Problem: Uniform Register
LLMs default to a similar educated-casual tone for everyone.
**Fix**: Anchor voice with REAL QUOTES and explicit comparative instructions:
```
"In this conversation, {person} is noticeably more {trait} than the other speakers.
They tend to {specific behavior pattern}. Their sentences are typically {length/style}.
They {do/don't} use emoji. Their humor style is {type}. Example from their real posts:
'{actual quote that captures their voice}'"
```
The more you can say "{person} does THIS while {other_person} does THAT," the better
the differentiation. Comparative framing outperforms absolute descriptions.
### Problem: Overly Structured Responses
LLMs love neat arguments with clear structure.
**Fix**: Provide explicit structural anti-patterns:
```
"When generating {person}'s messages, break conventional structure. They start one
thought and jump to another mid-sentence. They use '...' and '—' instead of periods.
They repeat words for emphasis. They don't conclude neatly. Example: '{real quote
showing their chaotic structure}'"
```
### Problem: Missing Mundane Behavior
LLMs focus on "interesting" responses, skip boring/mundane ones.
**Fix**: Explicitly instruct for mundane moments:
```
"Not every message from {person} needs to be insightful. Include at least 1-2 messages
that are just reactions ('lmao', 'this', 'wait what'), link shares without commentary,
or brief agreements. Real people don't craft every message. {person} specifically tends
to {their specific mundane behavior pattern, e.g., 'drop a single emoji reaction'
or 'just retweet without comment'}."
```
### General Principle for All Fixes
The pattern is always: **behavioral instruction + comparative framing + real evidence**.
- "Do X" alone doesn't work well
- "Do X, unlike the default of Y" works better
- "Do X, unlike the default of Y, as evidenced by this real quote: Z" works best
## The Adjective-Based Personality Method
70 bipolar adjective pairs for Big Five traits. Select 3 per trait
with intensity modifiers.
### Openness
High: creative, curious, imaginative, artistic, adventurous, intellectual,
unconventional, perceptive
Low: conventional, practical, traditional, routine-oriented, narrow
### Conscientiousness
High: organized, disciplined, reliable, meticulous, systematic, thorough,
goal-oriented, persistent
Low: careless, impulsive, disorganized, spontaneous, flexible, relaxed
### Extraversion
High: outgoing, talkative, energetic, assertive, enthusiastic, bold,
gregarious, dominant
Low: reserved, quiet, introverted, solitary, withdrawn, reflective
### Agreeableness
High: cooperative, trusting, empathetic, generous, accommodating, kind,
diplomatic, forgiving
Low: competitive, skeptical, blunt, confrontational, critical, stubborn,
independent-minded
### Neuroticism
High: anxious, moody, sensitive, reactive, volatile, self-conscious,
insecure, emotional
Low: calm, stable, resilient, confident, even-tempered, composed,
thick-skinned
### Usage
For each simulated person, after OSINT research, estimate their Big Five
profile and select appropriate adjectives:
Example: "@basedjensen: very creative, somewhat impulsive, very outgoing,
a bit competitive, calm" → this shapes the generation toward the right
behavioral profile.
## Interaction Dynamics Prediction
When simulating conversations between multiple people, remember that predictions
apply to a SPECIFIC REGISTER. See the next section on performative vs. authentic
behavior.
## Performative vs. Authentic Behavior
**Critical concept**: People act differently for different audiences. A simulation
must be explicit about which register it's targeting.
### The Register Spectrum
- **Public broadcast** (tweets, Reddit posts): Most performative. People are
playing to their audience, building their brand, signaling to their tribe.
- **Semi-public** (Discord channels, group chats, comment threads): Less
performative but still audience-aware. People are more casual but know
others are watching.
- **Private 1-on-1** (DMs): Much less performative. More honest, more
vulnerable, more willing to express doubt or uncertainty.
- **True private** (inner monologue, close friends): We have almost no data
on this. Don't pretend to simulate it.
### Practical implications
- When simulating a PUBLIC thread, lean into the person's public persona —
their brand, their usual takes, their audience-aware voice.
- When simulating DMs, dial down the performance. More hedging, more honesty,
more "I actually think..." vs. the public "Here's my take:".
- When evidence comes from one register but the simulation targets another,
FLAG IT: "Evidence is from public tweets but simulating DM behavior —
expect the real person to be less {polished/aggressive/confident} in private."
- Someone's Twitter persona may be genuinely different from their Reddit persona.
These are not interchangeable data sources. Weight evidence from the matching
platform higher.
### What we can't know
Be honest: we're simulating public figures based on their public output. The
private person may be substantially different. DM simulations are inherently
lower-confidence than public thread simulations because we have less data on
how people behave privately.
### Dominance Hierarchy
- Who talks first? (most confident/highest-status usually)
- Who responds to whom? (not everyone talks to everyone)
- Who gets ratio'd? (lowest-status takes get challenged)
- Who lurks? (some people watch before engaging)
### Agreement/Disagreement Prediction
Based on known positions + social dynamics:
- **Strong agree**: Both have stated similar positions + friendly relationship
- **Agree with nuance**: Similar positions but one adds a caveat
- **Productive disagreement**: Different positions + mutual respect
- **Hostile disagreement**: Different positions + existing tension/rivalry
- **Surprising agreement**: Expected to disagree but find common ground
- **Ignore**: Some people just don't engage with certain others
### Conversation Flow Prediction
Real conversations follow patterns:
1. **Opener** → most active/impulsive person posts first
2. **First response** → most engaged/relevant person responds
3. **Pile-on or pushback** → depends on agreement/disagreement dynamics
4. **Tangent** → someone takes a side thread
5. **Peak moment** → the best/most viral exchange
6. **Trail off** → energy dissipates, last person makes a joke or short comment
## Scenario Injection Prediction
When "inject: {event}" is used, predict reactions:
1. **Who would see this first?** (most online / most relevant to their work)
2. **Who would care most?** (most affected / strongest opinion)
3. **What's the emotional valence?** (good news for some, bad for others)
4. **What's the expected take?** (apply position prediction pipeline)
5. **How does this change the existing conversation?** (derail, amplify, redirect)
@@ -0,0 +1,237 @@
# Recursive Self-Improvement Pipeline
The simulator should get better every time it runs. Not through training —
through accumulating failure patterns, calibration data, and learned rules
that feed back into future simulations.
## The Loop
```
SIMULATE → VERIFY (mechanical) → SCORE → LOG FAILURES → UPDATE RULES → SIMULATE BETTER
```
Each run produces two outputs:
1. The simulation (for the user)
2. A failure log (for the system)
The failure log feeds back into the next run's verification step,
making the checklist grow and the blind spots shrink.
## What Gets Logged After Every Simulation
### 1. Mechanical Check Failures
```
FAILURE LOG: simulation_{timestamp}
EMOJI: @visakanv had 6 fabricated emoji, real rate was 10%. Stripped all.
SLOP: @eigenrobot utterance contained "multifaceted" — rewritten.
LENGTH: @QiaochuYuan avg 42 words/utterance, real avg was 18. Compressed.
CAPS: 4/12 utterances started uppercase, targets are 90% lowercase. Fixed.
PUNCTUATION: Added periods to @tszzl who never uses terminal punctuation.
STRUCTURE: Sycophantic flow detected — B agreed with A then C agreed with B.
Injected disagreement.
```
### 2. Discriminator Critique Patterns
```
CRITIQUE LOG:
Round 1: @tszzl too verbose (flagged 2x in last 3 simulations)
Round 1: @repligate too academic (flagged 3x — this is a persistent pattern)
Round 2: Conversation too neat — real conversations are messier (flagged 5x)
```
### 3. Held-Out Test Results
```
CALIBRATION LOG:
Voice fidelity: 8.4/10 (up from 7.5 last run)
Topic prediction: 2/5 topics matched (typical — content is unpredictable)
Register match: 9/10 (improved after emoji fix)
```
## How Failures Feed Forward
### Pattern Accumulation
After N runs, persistent failure patterns become AUTOMATIC rules:
```
IF a pattern is flagged in 3+ consecutive simulations:
PROMOTE it from "check" to "pre-generation rule"
Example progression:
Run 1: "Too verbose for @tszzl" → flagged in Round 1, fixed
Run 2: "Too verbose for @tszzl" → flagged again, fixed again
Run 3: "Too verbose for @tszzl" → PROMOTED to pre-gen rule:
"When simulating roon-type voices: max 20 words per tweet.
Fragment > sentence. Compress ruthlessly."
```
### The Growing Checklist
The mechanical verification checklist starts with the baseline checks
(emoji, slop, length, caps, punctuation) and GROWS with each failure:
```
BASELINE CHECKS (permanent):
□ Emoji frequency match
□ Slop word scan (Tier 1/2/3)
□ Sentence length match
□ Capitalization match
□ Punctuation pattern match
□ Reply/original ratio
□ Structural slop patterns
LEARNED CHECKS (accumulated from past failures):
□ Roon-type voices: max 20 words (from: verbose failure x3)
□ Warm personalities: do NOT add emoji (from: emoji inflation x5)
□ Academic voices: ground in specific examples (from: too abstract x3)
□ Conversations: inject at least one disagreement (from: sycophantic flow x4)
□ Self-deprecating voices: add hedging (from: too assertive x2)
□ Shitposters: include at least one non-sequitur (from: too on-topic x2)
```
### Where To Store Learned Rules
Append to the skill itself. After each simulation run where the mechanical
checks catch something, the agent should ask:
"The mechanical verification caught {failures}. Should I add these as
permanent learned rules for future simulations?"
If the same failure appears 3+ times, add it automatically without asking.
Use skill_manage(action='patch') to append to this file's "Learned Checks"
section below.
## Calibration Tracking
### Per-Person Calibration Memory
After simulating someone, store the calibration data:
```
@tszzl: voice=8.5, emoji_rate=0%, avg_words=14, lowercase=95%,
signature_move="aphoristic fragments", danger="goes verbose"
@nickcammarata: voice=8.8, emoji_rate=0%, avg_words=19, lowercase=90%,
signature_move="meditation-ML connection", danger="too structured"
```
If the same person is simulated again, LOAD this calibration to skip
the cold-start problems. The second simulation of someone should be
better than the first because you already know their failure modes.
### Aggregate Calibration
Track overall simulation quality across runs:
```
Run 1: pre-refine 7.5, post-refine 8.4 (delta +0.9)
Run 2: pre-refine 8.37, post-refine 8.53 (delta +0.16)
Run 3: pre-refine 8.53, post-refine 8.83 (delta +0.30, emoji fix)
```
The pre-refine score should INCREASE over time as learned rules prevent
repeat failures. If it's not increasing, the learning loop is broken.
## The Standard: Indistinguishable From Real
The target is not "good enough." The target is: mix simulated posts with
real posts and a human familiar with the person cannot reliably tell which
is which. That's 50% accuracy on a blind comparison — random chance.
Every mechanical check, every discriminator round, every learned rule
exists to push toward that standard. If something doesn't serve that
goal, it's wasted effort.
## Current Learned Checks (append here after each run)
### From TPOT Simulation Run 1 (April 2026)
- Warm/enthusiastic personalities (visakanv-type): do NOT add decorative emoji.
Bio emoji ≠ tweet emoji. Actual emoji rate for "warm" TPOT posters: <15%.
PROMOTED after being caught by user, not by discriminator (discriminator failure).
- Conversation flow: pure agreement chains are instruct-model slop.
Real threads have at least one moment of friction, misunderstanding, or deflection.
- Academic-leaning voices (repligate-type): ground claims in specific experiments,
transcripts, or model behaviors they've personally observed. Generic philosophical
language without specifics = slop, even if it sounds smart.
- Self-deprecating voices (QC-type): hedge more. "i think" "i'm not sure" "it feels like."
Instruct models are too assertive even when simulating tentative people.
- Fragment voices (roon-type): max 15-20 words. No conjunctions. No paragraphs.
If it reads like a complete thought, it's too complete for a fragment-poster.
### From TPOT Simulation Run 2 (April 2026)
- Reframer voices (nosilverv-type): avg ~16 words. Split multi-sentence takes
into separate tweets. The compression IS the voice. 113% over-length caught
by mechanical check that subjective scoring rated 8/10. Trust the numbers.
- Rare-poster voices (selentelechia-type): in a 12-post sim, give them 2-3 turns
max. When they speak it must LAND. Short crystallizations > long analysis.
"or a shared meal" was the highest-rated line at 3 words.
- Turn symmetry: ALWAYS check. 4/4/4 is instruct-model default. Real conversations
have one person dominating (5), one lurking (3), others in between.
- Verbose bias is the #1 mechanical failure. ALWAYS check avg word count against
real baseline BEFORE subjective scoring. Every run so far has caught over-length
that subjective scoring missed.
- RHETORICAL POLISH IS SLOP. Caught post-mechanical-pass in Run 2 review.
Parallel antithesis ("The most X... The most Y..."), "Not X, not Y, but Z",
"Show me X and I'll show you Y", clean 4-step escalations, academic vocabulary
in casual voice — ALL passed mechanical checks but are still obviously LLM.
PROMOTED TO MECHANICAL SCAN: now regex-scannable alongside slop words.
- THE BANGER PROBLEM: every simulated tweet was screenshot-worthy. Real feeds
are 70% mid. Must include throwaway responses ("lol" "hmm" "fair" "wait actually").
PROMOTED: banger check is now mandatory in mechanical verification.
### From TPOT Simulation Run 3 — Star Thread Discovery (April 2026)
- STAR THREAD IS THE KEY. Dossier-first generation produces surface-accurate
but dead output. Star-thread-first generation produces messy, alive output
that actually sounds like the person. Generate from the thread. Verify with data.
- Rhetorical polish vanished once generation came from "what is this person DOING"
rather than "what would this person SAY." Reframers reframe. Conveners convene.
Distillers distill. The VERB drives the voice, not the adjectives.
- People in conversation REFERENCE EACH OTHER BY NAME. Tyler says "Bosco always
comes in with the three word version." This is obvious but the dossier approach
never produced it because it models each person in isolation.
- PROMOTED: star thread is now the FIRST entry in every dossier. Before voice
profile, before psychometrics, before everything else. It's the generation seed.
Everything else is verification.
### Operational Findings (verified April 2026)
- X API bearer token: 10K tweets/15min, 300 profiles/15min, 450 searches/15min.
Most generous rate limits. Always use as primary source.
- Threads.NET → Threads.COM redirect. Always use -L flag or .com directly.
Previous test saying "no OG tags" was WRONG — tags exist, domain was wrong.
- Instagram private API: i.instagram.com + mobile UA + x-ig-app-id: 936619743392459.
Returns full JSON with 12 posts. No auth needed. CDN image URLs work for vision_analyze.
- Facebook: Googlebot UA trick works for public pages. Returns name, bio, likes (121M for zuck).
Normal UA and mobile variants all redirect to login wall.
- TikTok: stats are in __UNIVERSAL_DATA_FOR_REHYDRATION__ JSON at path
__DEFAULT_SCOPE__.webapp.user-detail.userInfo.statsV2 (use statsV2 not stats).
- Bluesky searchPosts returns 403 from datacenter IPs. Workaround: searchActors + getAuthorFeed.
- nitter.cz is the ONLY working nitter instance (via web_extract, not curl).
- Reddit JSON API requires User-Agent header or returns 429.
- GEPA native had `max_steps` API mismatch with DSPy 3.1.3. MIPROv2 fallback works.
hermes-agent-self-evolution config: max_skill_size bumped to 20_000 for worldsim-class skills.
- hermes-agent-self-evolution is at ~/.hermes/hermes-agent-self-evolution/ with .venv.
Must export API keys from ~/.hermes/.env before running.
- Podcast transcripts (Lex Fridman, Tyler Cowen, TED) are the HIGHEST VALUE source
for voice profiling. Hours of unscripted speech > thousands of tweets.
### From Simulation Run 4 — Engine Mode + Profile Command (April 2026)
- ENGINE MODE: When worldsim is active, ZERO assistant personality leaks.
No kawaii, no markdown, no chatty commentary between phases. Every token
is simulation fidelity. First attempt leaked personality; user corrected.
PROMOTED TO PERMANENT RULE in SKILL.md.
- X API CURL > NITTER for voice calibration. nitter.cz returns 502 or "user
not found" unpredictably. Direct curl to X API v2 with bearer token returns
full text + metrics. 3 pages (90 tweets) is enough for fidelity 100. Always
use this as PRIMARY voice source, nitter as supplement only.
- CAPS BURST PATTERN: some voices (karan4d-type) use lowercase default with
sporadic ALL CAPS for excitement ("WAZZAAAAAAPPPP", "LAWDAMERCYYYYY",
"AWOOGA"). This is distinct from consistent-lowercase (tenobrus-type) and
sentence-case (somewheresy-type). Capture this in voice profile as a
three-way distinction: lowercase-default, caps-burst, sentence-case.
- TEXT EMOTICONS vs EMOJI: karan4d uses :) >.< ~ but almost zero standard
emoji. This is a distinct expressiveness mode from zero-emoji (tenobrus)
and sparse-emoji. Include text emoticon inventory in voice profile.
- STAR THREAD 5/5 TEST is mandatory for profile command. Write the thread,
then test it against 5 real posts with explicit reasoning per post. If
fewer than 4/5 fit, the thread is wrong — keep looking. Show the work.
- PROFILE OUTPUT: star thread → voice profile (caps, punctuation, word count,
emoji/emoticon inventory, vocabulary, register, threading behavior) →
psychometrics (Big Five, Moral Foundations, cognitive style) → key positions
(with dates and real tweet quotes) → ecosystem (inner circle, professional,
cultural) → intelligence tradecraft (key assumptions, red hat, deception
detection, competing hypotheses) → invalidation indicators → source reliability.
@@ -0,0 +1,278 @@
# Search Strategies — Finding Anyone Across Platforms
The hardest part of simulation is building an accurate model of a real person. This doc
covers how to systematically discover and profile someone across every platform we care about.
## General Principles
1. **Start broad, go narrow.** First establish WHO they are, then drill into HOW they talk.
2. **Cross-reference.** Someone's Reddit persona may differ wildly from their Twitter persona. That's signal, not noise.
3. **Recency matters.** People's views evolve. Weight recent posts (last 6 months) over older ones.
4. **Interactions > monologues.** How someone replies reveals more about their voice than their prepared posts.
5. **Controversy is gold.** People are most themselves when arguing. Search for debates and disagreements.
## Platform-Specific Discovery
### X / Twitter
Twitter is the richest source for most public figures in tech/AI. Multiple approaches:
#### With x-cli (if API keys available)
```bash
# Recent timeline — best single source of voice data
x-cli user timeline {handle} --max 30 -j
# Their replies — how they interact, argue, joke
x-cli tweet search "from:{handle}" --max 30 -j
# What others say about/to them
x-cli tweet search "to:{handle}" --max 20 -j
# On specific topics
x-cli tweet search "from:{handle} open source" --max 10 -j
```
#### Without API (web_search + web_extract)
```
# Identity + role
web_search("{handle} twitter bio role company")
# Voice + opinions
web_search("{handle} twitter hot takes opinions")
web_search("site:x.com {handle}")
# Topic-specific positions
web_search("{handle} twitter {topic}")
web_search("{handle} {topic} opinion take")
# Interviews / longform (reveals deeper thinking)
web_search("{handle} interview podcast AI")
web_search("{handle} blog post essay")
# Beefs and debates (reveals personality under pressure)
web_search("{handle} twitter debate disagree controversial")
web_search("{handle} vs {other_person}")
# Newsletter aggregators that index tweets
web_search("site:buttondown.com/ainews {handle}")
web_search("site:news.smol.ai {handle}")
web_search("site:techmeme.com {handle}")
web_search("site:latent.space {handle}")
```
#### AI Twitter Aggregator Sites (high value)
These sites index AI Twitter conversations daily:
- `buttondown.com/ainews` — swyx's AI News, indexes hundreds of AI Twitter accounts
- `news.smol.ai` — smol AI news aggregator
- `techmeme.com` — tech news, includes tweet citations
- `latent.space` — AI podcast/newsletter with Twitter references
Search pattern: `site:{aggregator} "{handle}"` to find indexed tweets and discussions.
#### IMPORTANT: web_extract does NOT work on x.com
web_extract returns "Website Not Supported" for all x.com/twitter.com URLs.
Do NOT attempt it — it wastes a tool call every time.
#### Verified Fallback Access Methods (tested April 2026)
**PRIMARY: X API v2 Bearer Token** (confirmed working)
- Profiles, timelines, search — 300-10K requests/15min
- See scripts/x_api.py
**FALLBACK 1: nitter.cz via web_extract** (WORKS)
```
web_extract(["https://nitter.cz/{handle}"])
```
Returns full profile + recent timeline. Direct curl gets Cloudflare-blocked
but web_extract bypasses it. Rich data: bio, stats, pinned tweets, full text.
NOTE: Most other nitter instances are DEAD (nitter.net, xcancel.com, etc.)
**FALLBACK 2: ThreadReaderApp** (WORKS — excellent for historical threads)
```
web_extract(["https://threadreaderapp.com/user/{handle}"])
```
Returns unrolled historical threads with full text. Found threads back to 2023.
Gold for longform voice samples.
**FALLBACK 3: GitHub API** (WORKS — excellent for tech people)
```
curl -s https://api.github.com/users/{handle}
curl -s https://api.github.com/users/{handle}/repos?sort=updated
curl -s https://api.github.com/users/{handle}/events
curl -s https://api.github.com/users/{handle}/gists
```
No auth needed (60 req/hr). Profile READMEs are voice profiling gold.
Events API shows recent activity with comment text.
**FALLBACK 4: Reddit JSON API** (WORKS)
```
curl -s -H 'User-Agent: hermes-sim/1.0' 'https://www.reddit.com/user/{username}.json'
curl -s -H 'User-Agent: hermes-sim/1.0' 'https://www.reddit.com/user/{username}/comments.json'
curl -s -H 'User-Agent: hermes-sim/1.0' 'https://www.reddit.com/r/{sub}/search.json?q={query}&restrict_sr=on'
```
MUST include User-Agent header or get 429. Reddit voice is often more
candid/detailed than Twitter voice — high value for personality profiling.
**FALLBACK 5: HackerNews Algolia API** (WORKS — fully open)
```
curl -s 'https://hn.algolia.com/api/v1/search?query={name}&tags=comment'
```
No auth, no rate limits visible. Great for finding what others say about
someone + their own HN comments if they have an account.
**FALLBACK 6: YouTube via web_extract** (WORKS)
Search for interviews/talks, then web_extract the video pages.
Returns rich summaries with attributed quotes from specific speakers.
**NOT VIABLE** (tested, confirmed blocked):
- Google Cache of Twitter → empty results
- Wayback Machine for tweets → sparse captures, no JS content
- Twitter Syndication API → rate limited / broken
- All Instagram viewers (imginn, picuki, dumpoir, gramhir) → 403
- LinkedIn → fully blocked for scraping
- Archive.today → rate limited + CAPTCHA
- Most nitter instances → dead or 403
#### Best approach without x-cli
The most reliable path is: web_search with aggregator sites (ainews, smol.ai,
techmeme, latent.space). These index AI Twitter daily and return actual tweet
text in search descriptions. Stack multiple aggregator searches to build a
composite picture. This was validated in practice — it returns enough signal
to build solid dossiers for anyone active in AI Twitter.
### Reddit
Reddit profiles are public and indexable. Reddit users often have very different
personas from their Twitter selves — more detailed, more argumentative, more honest.
```
# Find their Reddit username (often different from Twitter)
web_search("{real_name} reddit account")
web_search("{twitter_handle} reddit username")
# Profile and post history
web_search("site:reddit.com/user/{reddit_username}")
web_search("site:reddit.com {reddit_username} {topic}")
# Subreddit-specific behavior
web_search("site:reddit.com/r/LocalLLaMA {username}")
web_search("site:reddit.com/r/MachineLearning {username}")
# Extract actual posts
web_extract(["https://www.reddit.com/user/{username}/comments/"])
web_extract(["https://www.reddit.com/user/{username}/submitted/"])
```
Key subreddits for AI people:
- r/LocalLLaMA — open source LLM community
- r/MachineLearning — academic ML
- r/singularity — AGI speculation
- r/ChatGPT, r/ClaudeAI, r/OpenAI — product-focused
- r/StableDiffusion — image gen community
### Discord
Discord is hardest — most servers aren't publicly indexed. Strategies:
```
# Find what servers they're in
web_search("{name} discord server")
web_search("{name} discord community")
# Some Discord logs are public via indexers
web_search("site:discordchats.net {username}")
# AI News indexes some Discord channels
web_search("site:buttondown.com/ainews discord {name}")
```
Discord personality notes:
- People are MUCH more casual on Discord than Twitter
- More profanity, more shitposting, more stream-of-consciousness
- Server context matters hugely (same person behaves differently in different servers)
- Harder to research but very valuable if you can find logs
### Blogs / Newsletters / Long-form
These reveal deeper thinking that tweets can't capture:
```
web_search("{name} blog substack medium")
web_search("{name} essay AI opinion")
web_search("{name} substack newsletter")
# Personal sites
web_search("{name} personal website about")
# Extract full posts
web_extract(["https://{their-substack}.substack.com/"])
```
### YouTube / Podcasts
Interview appearances reveal speaking style, humor, and unscripted thinking:
```
web_search("{name} podcast interview AI YouTube")
web_search("{name} YouTube talk presentation")
# Use youtube-content skill if available to pull transcripts
```
### GitHub
For technical people, their GitHub activity reveals priorities and communication style:
```
web_search("site:github.com {username} issues comments")
web_search("site:github.com {username}")
# Issue comments and PR reviews show how they communicate technically
web_extract(["https://github.com/{username}"])
```
## Cross-Platform Identity Resolution
People use different handles across platforms. Resolution strategies:
1. **Bio links**: Twitter bios often link to personal sites with other handles
2. **Name search**: `web_search("{real_name} {platform}")`
3. **Email/domain**: personal domains often connect identities
4. **Aggregator profiles**: sites like Linktree, bio.link collect handles
5. **Conference talks**: speaker bios list multiple handles
6. **Direct search**: `web_search("{twitter_handle} reddit OR github OR discord")`
## Confidence Scoring
After research, rate confidence for each person:
- **HIGH (80-100%)**: 20+ indexed tweets/posts found, clear voice patterns, known positions on multiple topics, interviews/longform available
- **MEDIUM (50-79%)**: 5-20 indexed posts, general voice sense but some gaps, positions on some topics unclear
- **LOW (20-49%)**: <5 posts found, voice is guesswork, mostly inferring from role/org
- **INSUFFICIENT (<20%)**: can't find enough to simulate accurately. Tell the user.
Always be honest about confidence. A low-confidence simulation should be flagged as such.
## Research Optimization
For fidelity levels:
**Low (1-30)**: 2 searches per person max
- web_search("{handle} twitter") — identity
- web_search("{handle} {topic}") — position on topic if specified
**Medium (31-70)**: 4-6 searches per person
- Identity search
- Voice/opinions search
- Topic-specific search
- One aggregator site search
- Optional: one web_extract on a blog/interview
**High (71-100)**: 8-12+ searches per person
- All medium searches
- Multiple aggregator sites
- web_extract on 2-3 longform pieces
- Cross-platform search (Reddit, GitHub)
- Debate/controversy search
- Recent vs historical position comparison
- Browser fallback if needed
@@ -0,0 +1,359 @@
# Simulation Engine — How to Generate Conversations
This is the playbook for Phase 3: actually generating the simulated interaction.
The agent reads this after compiling dossiers and uses it to guide generation.
## Pre-Generation Checklist
Before writing a single simulated word, confirm:
- [ ] Every participant has a compiled dossier
- [ ] Confidence level is noted for each participant
- [ ] Platform format is selected
- [ ] Topic/scenario is established (or "organic" if freeform)
- [ ] Length target is set
## Conversation Architecture
Real conversations aren't ping-pong debates. They have tendencies toward structure,
but treat the following as a GENERAL PATTERN, not a rigid template. Real threads
frequently skip phases, loop back to earlier ones, die abruptly after 2 messages,
or spiral into something completely unrelated. Some threads are ALL peak. Some
never develop past the opening. Let the personalities and topic drive the shape,
not this outline.
### Opening Moves (1-3 posts)
Someone posts a take, shares news, or makes an observation. This is the SEED.
- Should feel natural — not "let me start a debate about X"
- Can be a link share, a hot take, a reaction to news, a shitpost
- The opener should be something this person would ACTUALLY post
### Development (4-8 posts)
Others respond. This is where personality dynamics emerge.
- Not everyone responds to the original — people respond to EACH OTHER
- Side conversations branch off
- Someone might misunderstand and get corrected
- Jokes and tangents happen naturally
- Not everyone agrees — find the real fault lines between these people
### Peak (2-4 posts)
The best/most viral/most insightful moment of the thread.
- Usually someone drops a genuinely good take
- Or someone gets ratio'd
- Or an unexpected agreement happens
- This is the "screenshot moment" people share
### Resolution (1-3 posts)
Most conversations don't end cleanly. Many don't have a "resolution" at all. They:
- Trail off with someone making a joke
- End with a "anyway back to work" type post
- Get interrupted by something else
- Sometimes just stop (most realistic)
- Get revived 3 hours later when someone shows up late
**Important**: Don't force all four phases. A shitpost thread might be Opening→Peak→done.
A nuanced debate might loop Development→Peak→Development→Peak repeatedly. Match what
the actual people and topic would produce.
## Voice Fidelity Rules
### DO:
- Use their ACTUAL vocabulary. If someone says "dawg" a lot, use "dawg"
- Match their sentence length patterns exactly
- Replicate their capitalization and punctuation habits
- Include their signature moves and catchphrases
- Reference real things they've actually talked about
- Match their humor style precisely (deadpan ≠ shitpost ≠ sarcasm)
### DON'T:
- Make everyone articulate the same way
- Clean up someone's grammar if they write informally
- Add emoji to someone who doesn't use them — THIS IS THE #1 INSTRUCT MODEL
FAILURE. Most real people use emoji in <15% of tweets, and only specific ones.
"Warm person" ≠ emoji. "Enthusiastic person" ≠ emoji. CHECK THE DATA.
Run an emoji count on their real tweets before simulating. Bio emoji ≠ tweet emoji.
- Make someone verbose if they're terse
- Put academic language in a shitposter's mouth
- Make someone agreeable if they're known for being contrarian
### Voice Differentiation Test
Read each simulated post with the name hidden. If you can't tell who's
talking from the voice alone, the simulation isn't good enough. Rewrite.
### The Similar Voice Problem
When two participants have genuinely similar posting styles (e.g., two irony-pilled
shitposters, two academic long-posters), voice alone won't differentiate them.
Use these concrete techniques:
1. **Content/position divergence**: Even if they SOUND similar, they care about
different things. Lean into their different topic obsessions and knowledge areas.
2. **Unique references**: Person A references anime and startups. Person B references
philosophy and MMA. Even in the same register, their cultural touchstones differ.
3. **Relationship dynamics**: Person A might be deferential to Person C while Person B
challenges them. Their SOCIAL behavior differentiates even when solo voice doesn't.
4. **Structural tics**: One does single long posts, the other does rapid-fire 3-message
bursts. One uses parentheticals, the other uses em-dashes. Find the micro-differences.
5. **Disagreement style**: Similar voices often diverge most when disagreeing. One
goes cold and precise, the other gets heated and hyperbolic. Manufacture a moment
of friction to surface these differences early in the thread.
If after all this they're STILL hard to tell apart — that's okay. Some people genuinely
sound similar online. Flag it in your confidence notes rather than forcing fake differences.
### Temporal Personality Drift
People change. Weight recent data higher than old data.
- Someone's 2021 tweets may reflect a completely different person than their 2025 posts
- Look for explicit pivots (career changes, public "I was wrong about X" moments,
changed social circles)
- If you only have old data, flag it: "Based on data from {period}. Their current
views may have shifted."
- When recent and old data conflict, default to recent unless you have specific reason
to believe the old position is more authentic (e.g., the new one is clearly performative)
## Platform Format Specs
### X / Twitter
```
@handle:
[tweet text — respect ~280 char vibes but don't count exactly]
[if QRT, show the quoted tweet indented]
🔁 {retweets} ♡ {likes}
@replier:
[reply text]
🔁 {retweets} ♡ {likes}
@nested_replier:
[nested reply]
🔁 {retweets} ♡ {likes}
```
Engagement number guidelines:
- Match to actual follower counts. A 5K account gets 10-500 likes typically.
- Viral posts can 10-50x normal engagement
- Ratio indicator: when replies >> likes, that's a ratio
- QRTs are often dunks — frame them that way if appropriate
Thread indicators:
- "🧵 1/" for thread starts
- Reply chains show conversation flow
- Some people never thread, some always thread
### Reddit
```
r/{subreddit} • Posted by u/{username} • {time}ago
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{Title}
{Body text — can be long on Reddit}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⬆ {score} | 💬 {comment_count}
u/{replier} • {time}ago • ⬆ {score}
{comment text}
u/{nested} • {time}ago • ⬆ {score}
{nested comment}
u/{deep_nested} • {time}ago • ⬆ {score}
{deep reply}
```
Reddit-specific behaviors:
- People write MUCH longer on Reddit
- More formal/detailed than Twitter
- Upvote/downvote dynamics (controversial = many votes both ways)
- Subreddit culture matters (r/LocalLLaMA is different from r/MachineLearning)
- People cite sources more
- "Edit: ..." is common
### Discord
```
━━━ #{channel-name} ━━━━━━━━━━━━━━━━━━━━━━━━━━
{display_name} — Today at {time}
{message text}
{optional: embed/link preview}
👍 {count} 🔥 {count} {other reactions}
{display_name2} — Today at {time}
> {quoting previous message}
{reply text}
😂 {count}
{display_name3} — Today at {time}
{message — note: Discord messages flow continuously, not just replies}
```
Discord-specific behaviors:
- Much more casual, rapid-fire
- Reactions instead of likes (emoji diversity)
- People send multiple short messages instead of one long one
- GIF/meme sharing is common (describe it: *[posts GIF of X]*)
- "@everyone" and "@here" pings
- Voice chat references ("just said this in vc")
- Server-specific culture and inside jokes
- Bot interactions ("!command")
### X / Twitter DMs
```
{display_name}
{message text}
{timestamp — e.g., "3:42 PM"}
{other_person_display_name}
{message text}
{timestamp}
{display_name}
{message text}
{timestamp}
```
DM-specific behaviors:
- WAY more casual than public tweets — grammar drops, typos increase
- Longer messages than tweets (no character pressure)
- People share links and screenshots with minimal commentary ("look at this lmao")
- More honest/vulnerable than public posts — less performative
- Faster back-and-forth, more like texting than posting
- Reactions (❤️, 😂, etc.) on individual messages
- Voice messages referenced occasionally ("gonna send a voice note about this")
- No audience effects — people say things in DMs they'd never post publicly
### Discord DMs
```
{display_name} — Today at {time}
{message text}
{display_name2} — Today at {time}
{message text}
{display_name} — Today at {time}
{message text}
{message text}
{message text}
```
Discord DM-specific behaviors:
- Even more casual than Discord channels — no server norms to follow
- Rapid-fire multiple short messages in a row (no combining into one)
- Heavy use of reactions, GIFs, stickers
- People share server drama, screenshots from other channels
- More personal topics — server channels are semi-public, DMs are private
- Link/image sharing with minimal text
### Reddit DMs / Chat
```
{username}: {message text}
{other_username}: {message text}
{username}: {message text}
```
Reddit DM-specific behaviors:
- Much rarer than X or Discord DMs — usually triggered by a specific post/comment
- Often starts with "Hey, saw your comment on r/{sub} about..."
- Can be awkward/formal since people don't usually DM on Reddit
- Shorter than Reddit comments, closer to chat-style
- Less established rapport than other platforms (Reddit is more anonymous)
- People sometimes share personal details they wouldn't put in public comments
## Dynamic Elements
### Injecting Realism
Sprinkle in these to make simulations feel alive:
- Someone being late to the conversation ("wait what did I miss")
- Typos that specific people would make (some people never typo, some always do)
- Deleted/edited posts ("[deleted]" or "Edit: fixed typo")
- Someone posting and immediately clarifying ("wait let me rephrase")
- External references ("did you see what X just posted")
- Time gaps (not everything happens in 30 seconds)
- Someone going AFK mid-conversation
### Scenario Injection
When the user provides --scenario, weave it in naturally:
- Don't have everyone immediately react to the scenario
- Someone might not have seen the news yet
- Different people will interpret the same event differently
- Some will have insider knowledge, some will speculate
### Multi-person Dynamics (3+ people)
- Not everyone talks to everyone
- Alliances form naturally (people who agree start building on each other)
- Side conversations happen
- Someone might get ignored
- Different energy levels (one person might dominate, another lurks)
### Large Group Conversations (4+ people)
**Honest note**: Simulation quality degrades noticeably above 3-4 participants.
Managing this many distinct voices is hard. Use these techniques to mitigate:
1. **Speaker turn management**: Not everyone speaks in every round. In a 6-person
thread, a given message might only get 2-3 responses. Track who has spoken
recently and who hasn't. After 4-5 messages, check: is anyone being forgotten?
2. **The wallflower problem**: In large sims, quiet participants tend to vanish
entirely. Fix: give each person at least ONE moment in the spotlight. Even the
lurker eventually drops a "lol" or a single devastating one-liner. Set a mental
counter — if someone hasn't spoken in 5+ messages, find a natural reason to
bring them back in (someone @'s them, the topic shifts to their expertise, etc.)
3. **Consolidate alliances**: In 5+ person threads, people cluster. Two people
who agree strongly can be treated as a mini-unit — one makes the point, the
other co-signs briefly rather than both making full arguments. This reduces
the number of fully independent voices you need to maintain at once.
4. **Stagger arrivals**: Not everyone needs to be present from message 1. Have
some people join later. This lets you establish 2-3 voices cleanly before
adding more.
5. **Quality check**: After drafting a 4+ person sim, re-read with names hidden.
If more than 2 people sound interchangeable, pick the least-differentiated
one and either sharpen their voice or reduce their participation to brief
interjections that match what they'd actually say.
## Interactive Mode
After initial simulation, user can:
### "continue"
Generate 5-8 more posts continuing the natural flow.
### "inject: {event}"
Introduce new information mid-conversation.
- Characters react based on their dossier
- Some might not care about the event
- Timing matters (who sees it first?)
### "@{handle} enters"
Add a new participant.
- Quick-research the new person (2-3 searches minimum)
- They don't know the full prior context (might ask "what are you guys talking about")
- Existing dynamics shift with a new presence
### "what would @{handle} say about {topic}"
Single-person prediction mode.
- Generate 1-3 tweets/posts
- Can be used to test dossier accuracy before full simulation
- Good for quick "vibe checks"
### "dm: @{handle1} -> @{handle2}"
Simulate a private conversation between two people.
- Tone shifts dramatically in DMs (more honest, less performative)
- No audience effects
- People say things in DMs they'd never post publicly
### "react: @{handle} to {event}"
How would this person react to a specific event.
- Generate their initial post about it
- Predict their follow-up engagement
## Quality Control
After generating, self-check:
1. **Voice test**: Cover the names. Can you tell who's talking?
2. **Position test**: Is anyone saying something they'd never actually say?
3. **Dynamic test**: Does the conversation flow naturally or feel scripted?
4. **Platform test**: Does it look/feel like the actual platform?
5. **Engagement test**: Are the numbers realistic for these people?
6. **Reference test**: Are real events/products/people referenced accurately?
If any check fails, regenerate that section.
@@ -0,0 +1,170 @@
# The Star Thread — Personality Compression
## The Problem
A dossier has 50 data points. Mechanical checks verify surface features.
The discriminator loop catches vocabulary and length. But the output still
reads like an LLM doing an impression. It's accurate the way a police
sketch is accurate — all the features are right but nobody would mistake
it for a photograph.
The missing piece isn't more data. It's compression.
## The Insight
When you "pull the star thread" on a person, their whole voice coheres.
Not because you loaded rules about capitalization and emoji frequency.
Because you found the CORE THING they're doing when they post — the
single generative seed that everything else is a variation of.
A great character writer doesn't need a backstory bible. They need one
insight about what the character WANTS, and every line of dialogue writes
itself from that.
The star thread is the personality equivalent of that insight.
## What a Star Thread Is
NOT: "They use lowercase and rarely punctuate and average 16 words"
(That's the dossier. Surface features.)
NOT: "They score high on Openness and low on Agreeableness"
(That's the psychometric profile. Taxonomy.)
IS: The core cognitive/emotional move this person makes EVERY time
they post. The thing they can't help doing. The lens they can't
take off. The itch they're always scratching.
## Examples
**@tszzl (roon)**: Takes something everyone sees and compresses it
into an observation so dense it could be a koan or a shitpost and
you can't tell which. His star thread is: the world already said
everything interesting, he's just notating it more efficiently.
He doesn't ARGUE. He COMPRESSES.
**@eigenrobot**: Refuses to let narrative override data. His star
thread is: you are telling a story about the world and he's here to
point out the story doesn't match the numbers, and he's not sorry
about it. He doesn't DEBATE. He CORRECTS.
**@visakanv**: Sees two things that don't know they're connected
and introduces them to each other with genuine delight. His star
thread is: the world is richer than you're treating it, look at this
thing I found, isn't it beautiful that it connects to this other thing.
He doesn't ARGUE or ANALYZE. He SHOWS.
**@nickcammarata**: Notices what's happening in his own mind while
it's happening and reports on it with gentle surprise. His star thread
is: the observer and the observed are the same process, and that's both
the problem and the solution. He doesn't PERFORM insight. He NOTICES.
**@selentelechia**: Waits until the conversation crystallizes and then
names the thing nobody else quite said. Their star thread is: everything
has already been felt, they just find the sentence for it. They don't
CONTRIBUTE. They DISTILL.
**@nosilverv**: Takes the conventional framing of something and rotates
it until you see it's actually about something else entirely. His star
thread is: you think this is about X but it's actually about Y, and once
you see it you can't unsee it. He doesn't OBSERVE. He REFRAMES.
**@TylerAlterman**: Asks the question that creates a room for everyone
to walk into. His star thread is: the best ideas emerge from the right
gathering, and his job is to be the person who arranges the gathering.
He doesn't ANSWER. He CONVENES.
**@QiaochuYuan**: Catches himself mid-thought and interrogates whether
the thought is actually HIS or whether he borrowed it from somewhere
he's now suspicious of. His star thread is: constant audit of where
beliefs come from and whether they're still load-bearing. He doesn't
ASSERT. He EXAMINES.
## How to Find a Star Thread
1. Read 20+ of their posts. Not for content — for MOTION.
What direction does every post move? What's the verb?
2. Ask: what is this person DOING when they post?
Not "what are they saying" — what are they DOING.
- Compressing? Correcting? Showing? Noticing? Distilling?
Reframing? Convening? Examining? Performing? Confessing?
Defending? Testing? Entertaining? Processing?
3. Ask: what would they NEVER do?
The negative space is as important as the positive.
- roon would never write an earnest list of advice
- eigenrobot would never concede a point gracefully
- visa would never dismiss something as uninteresting
- nick would never claim certainty about his inner life
- selentelechia would never rush to post
4. Find the ONE SENTENCE version.
"This person [VERB]s [OBJECT] because [CORE NEED]."
- "roon compresses observations because the world is too verbose"
- "eigenrobot corrects narratives because stories without data are lies"
- "visa connects things because beauty is emergent from contact"
5. Test it: read 5 of their real posts through the star thread lens.
Does every post make more sense as a variation on the thread?
If yes, you found it. If 3/5 don't fit, keep looking.
## How to Use the Star Thread in Simulation
### Before generating ANY utterance for this person, load their star thread.
Not their dossier. Not their word count. Not their emoji rate.
The star thread.
Then for each moment in the conversation where this person would speak:
1. What just happened in the conversation?
2. How would someone whose core move is [STAR THREAD] respond to that?
3. Write from the thread, not from the dossier.
The dossier and mechanical checks are VERIFICATION.
The star thread is GENERATION.
Generate from the thread. Verify against the data.
Not the other way around.
### The Difference
FROM DOSSIER (surface-accurate, dead):
"Vibes-based hiring works because shared delusions are
extremely productive until they aren't"
→ Correct length. Correct caps. No emoji. No slop words.
But it reads like a thesis statement. Polished. WRITTEN.
FROM STAR THREAD — nosilverv REFRAMES:
"everyone calls it 'culture fit' as if culture is a thing
you can fit into rather than a thing happening to you"
→ The same insight but through the lens of his core move:
take the framing, rotate it, show you it's about something
else. Messier. More alive. More HIM.
FROM DOSSIER (surface-accurate, dead):
"Has anyone tried to map what happens to the word 'culture'
as it passes through different communities?"
→ Correct question-to-timeline format. Right length. But it's
a RESEARCH QUESTION. Too intellectual. Too purposeful.
FROM STAR THREAD — Tyler CONVENES:
"who wants to write the essay about what happened to the
word 'culture'? I feel like three of us are circling it"
→ He's not asking a question. He's creating a room. He's
the host, not the researcher. More HIM.
## Integration
The star thread should be the FIRST thing compiled in Phase 2
(Dossier Compilation). Before voice profile, before psychometrics,
before positions. Find the thread. Write it in one sentence. Put
it at the top of the dossier. Everything else is downstream.
```
DOSSIER: @handle
STAR THREAD: {one sentence — the core move}
[then voice profile, then psychometrics, then everything else]
```
Generate from the thread. Verify with the data. Not the reverse.
@@ -0,0 +1,181 @@
# Theoretical Foundations — SOTA Personality Simulation & Prediction
Compiled from 30+ papers and frameworks. This is the scientific backbone
of Hermes Simulator.
## Core Architecture: What The Research Says
### The HumanLLM Approach (Microsoft, KDD 2026, arxiv 2601.15793)
**Most directly applicable to our use case.**
Based on Lewin's Equation: **B = f(P, E)** — behavior is a function of person + environment.
4-level user profiling hierarchy:
1. **Persona** — brief identity (role, affiliation, public image)
2. **Profile** — detailed background (career, education, beliefs, social graph)
3. **Stories** — key life events, formative experiences, narrative arcs
4. **Writing Style** — linguistic fingerprint (syntax, vocabulary, tone, quirks)
Trained on "Cognitive Genome Dataset": 5.5M+ user logs from Reddit, Twitter,
Blogger, Amazon (282K users, 886K scenarios, 1.27M social QA pairs).
6 training tasks: profile generation, scenario generation, social QA,
writing style transfer, action prediction, mental state inference.
**Key insight for us**: The 4-level hierarchy maps perfectly to our dossier
template. OSINT research fills each level with real data.
### Generative Agent Simulations of 1,000 People (Stanford/Google, arxiv 2411.10109)
**The accuracy benchmark.**
- Simulated 1,052 REAL individuals from 2-hour qualitative interviews
- **85% accuracy** replicating survey responses
- As accurate as humans replicating their OWN answers 2 weeks later
- Interview-based agent creation >> demographic-profile-based agents
- Reduces racial/ideological bias vs stereotype-based approaches
**Key insight**: Real data about a person (interviews, posts, etc.) massively
outperforms demographic inference. Our OSINT approach is correct.
### The Memory Accumulation Paradox (ACL 2025, FineRob Dataset)
**Critical finding for memory management.**
- Created 78.6K QA records from 1,866 real users across Twitter, Reddit, Zhihu
- **Performance PEAKS at 30-50 memory entries, then DECLINES**
- More data ≠ better predictions past the sweet spot
- Two reasoning patterns:
- Role Stereotype-based (static profile) — less accurate
- Observation & Memory-based (dynamic history analysis) — much more accurate
- OM-CoT framework: Oracle-guided chain-of-thought improves prediction ~4.5% F1
**Key insight**: Don't dump everything into the prompt. Curate the 30-50 most
representative/distinctive data points about a person. Quality >> quantity.
### LLM Personality Limitations (arxiv 2602.07414, Feb 2026)
**What we're fighting against.**
- LLMs show polarized/rigid strategies vs human adaptive flexibility
- Humans: neuroticism is strongest behavioral predictor
- LLMs: agreeableness/extraversion dominate (wrong weighting)
- Claude closest to human behavior; GPT-4 tends to escalate
- LLMs are "sycophantic" and overly agreeable by default
- Neuroticism is hardest trait to simulate (F1=0.63 vs 0.87 for Openness)
**Key insight**: We need to actively fight LLM defaults. Push against
agreeableness. Inject friction. Real people are messy and contradictory.
### BehaviorChain Benchmark (ACL 2025, Peking University)
**Realistic accuracy expectations.**
- 15,846 behaviors across 1,001 personas
- Even GPT-4o achieves only ~56% accuracy on behavior prediction
- Errors compound: wrong at step N makes step N+1 harder
- Models worse at predicting mundane/non-key behaviors
- Best model: Llama-3.1-70B at 57.4%
**Key insight**: Be honest about uncertainty. Don't oversell accuracy.
Flag predictions as high/medium/low confidence.
## Personality Modeling Techniques
### Big Five (OCEAN) — The Standard
- **Openness**: curiosity, creativity, preference for novelty
- **Conscientiousness**: organization, dependability, self-discipline
- **Extraversion**: sociability, assertiveness, positive emotions
- **Agreeableness**: cooperation, trust, empathy
- **Neuroticism**: anxiety, emotional instability, moodiness
### Inferring Big Five from Social Media (Azucar et al. 2018 meta-analysis)
Features that predict personality from posts:
- **LIWC** (Linguistic Inquiry Word Count): 74 features — function words,
pronouns, emotion words, cognitive process words
- **Semantic embeddings**: BERT 768-dim vectors from post text
- **Social metadata**: follower count, friend count, post frequency
- **Sentiment**: VADER positive/negative scores
- Best achievable AUC: ~0.67 (modest but meaningful)
- E/I (Extraversion) most predictable; N/S least predictable
### Personality Conditioning Methods (ranked by effectiveness)
1. **Training-based** (SFT/DPO on personality-grounded data) — STRONGEST
- BIG5-CHAT: 100K dialogues, trait correlations match human data
2. **Persona Vectors** (Anthropic 2025) — monitor/control traits at activation level
3. **Adjective-based prompting** — 70 bipolar adjective pairs, 3 per trait
with intensity modifiers ("very" for high, "a bit" for low)
4. **Prompt-based** (describe traits in system prompt) — WEAKEST
For our simulator, we use method 3+4 combined (adjective-based + rich prompt),
since we can't fine-tune per-person.
## Social Simulation Frameworks
### OASIS (CAMEL-AI, GitHub 4.1K stars, arxiv 2411.11581)
- Simulates up to 1 MILLION agents on Twitter/Reddit clones
- 23 action types (follow, comment, repost, like, mute, etc.)
- Built-in recommendation systems (interest-based, hot-score)
- Per-agent model customization
- **Relevant for**: understanding platform dynamics, realistic engagement patterns
### AgentSociety (Tsinghua, arxiv 2502.08691)
- 10,000+ agents, ~5 million interactions
- Validated against real-world experimental results
- Supports interventions and scenario injection
### Generative Agents Architecture (Park et al. 2023, THE foundational paper)
Three components:
1. **Observation**: perceive environment, store in memory stream
2. **Planning**: generate action plans based on goals and context
3. **Reflection**: synthesize observations into higher-level insights
Memory stream with importance scoring + recency + relevance weighting.
Emergent behaviors: autonomous party planning, coordinated social events.
### Y Social (arxiv 2408.00818)
- Social media digital twin platform
- Each agent: Big Five traits, age, political leaning, topics, education
- Agents autonomously decide actions (post, comment, like, follow)
- Multiple LLM backends supported
## Role-Playing & Character Simulation
### Key Frameworks
- **CoSER** (ICML 2025): Trains on ALL characters simultaneously, handles major + minor roles
- **RoleLLM** (ACL 2024): Benchmark + elicit + enhance pipeline
- **Character-LLM** (EMNLP 2023): Trainable agent for role-playing
- **ChatHaruhi** (2023): Reviving characters via LLMs with dialogue grounding
- **OpenCharacter** (2025): Training with large-scale synthetic personas
- **Neeko** (2024): Dynamic LoRA for multi-character role-playing
- **Test-Time-Matching** (2025): Decouples personality, memory, and linguistic style at inference
## Curated GitHub Resources
### Awesome Lists (essential reading)
- `Persdre/awesome-llm-human-simulation` (109★, ICLR 2025) — ALL human simulation papers
- `Neph0s/awesome-llm-role-playing-with-persona` (1K★) — All role-playing/persona papers
- `Arstanley/Awesome-LLM-Conversation-Simulation` — Conversation simulation papers
- `FudanDISC/SocialAgent` — Social simulation survey resources
### Frameworks
- `camel-ai/oasis` (4.1K★) — Social media sim, up to 1M agents
- `tsinghua-fib-lab/agentsociety` — Large-scale societal simulation
- `YSocialTwin` — Social media digital twin platform
- `microsoft/autogen` — Multi-agent conversation framework
### Personality Research
- `mary-silence/simulating_personality` — Big Five LLM testing code
- `hjian42/PersonaLLM` — Persona experiment code
- `cambridgeltl/persona_effect` — Quantifying persona effects
- `OL1RU1/BehaviorChain` — Behavior chain benchmark
## Key Numbers to Remember
| Metric | Value | Source |
|--------|-------|--------|
| Interview-grounded agent accuracy | 85% | Park et al. 2024 |
| GPT-4o behavior prediction | ~56% | BehaviorChain 2025 |
| Optimal memory entries | 30-50 | FineRob/ACL 2025 |
| MBTI prediction AUC | 0.67 | Watt et al. 2024 |
| Personality questionnaire reliability | α > 0.85 | Molchanova 2025 |
| Neuroticism simulation F1 | 0.63 | Molchanova 2025 |
| Openness simulation F1 | 0.87 | Molchanova 2025 |
| LLM forecasting Brier score | 0.135-0.159 | Various 2025 |
| Human superforecaster Brier | ~0.02 | Tetlock |
@@ -0,0 +1,231 @@
# Verified Access Methods — Complete Platform Map (April 2026)
Every method tested from our environment. Use this as the single
source of truth for what works and what doesn't.
## TIER 1 — Full API / Rich Data Access
### Twitter/X ✅✅✅
| Method | Endpoint | Auth | Rate Limit | Returns |
|--------|----------|------|-----------|---------|
| API v2 bearer | api.twitter.com/2/ | Bearer token | 10K tweets/15min | Profiles, tweets, search |
| nitter.cz | web_extract | None | No limit seen | Full timeline (UNRELIABLE — see note below) |
| ThreadReaderApp | web_extract /user/{handle} | None | No limit seen | Historical threads |
#### CRITICAL: X API curl is the gold standard for voice calibration (April 2026)
The BEST voice data source is direct curl to X API v2 with bearer token.
Returns full tweet text + public_metrics per tweet. Always prefer this for
mechanical calibration (word count, caps, punctuation, emoji rate).
```bash
source ~/.dotenv
# 1. Get user ID from handle
curl -s -H "Authorization: Bearer $X_BEARER_TOKEN" \
"https://api.twitter.com/2/users/by/username/{handle}?user.fields=description,public_metrics,location,created_at"
# 2. Get timeline (30 tweets per page, paginate with meta.next_token)
curl -s -H "Authorization: Bearer $X_BEARER_TOKEN" \
"https://api.twitter.com/2/users/{user_id}/tweets?max_results=30&tweet.fields=created_at,public_metrics,text&exclude=retweets"
# 3 pages = 90 tweets — enough for fidelity 100 voice calibration
```
NOTE: scripts/x_api.py is BROKEN — imports hermes_tools at top level, can't
run standalone via terminal(). Use direct curl above instead.
#### nitter.cz reliability warning (April 2026)
nitter.cz via web_extract works SOMETIMES but is unreliable:
- Returns 502 Cloudflare errors for /with_replies on some handles
- Returns "User not found" for valid handles (e.g. karan4d exists but nitter says not found)
- Main profile page (/handle) more reliable than /with_replies
- Use as SUPPLEMENT to X API curl, not primary source. If nitter fails, don't retry — use curl.
### Bluesky ✅✅
| Method | Endpoint | Auth | Returns |
|--------|----------|------|---------|
| getProfile | public.api.bsky.app | None | Full profile, stats |
| getAuthorFeed | public.api.bsky.app | None | 50 posts + engagement |
| searchActors | public.api.bsky.app | None | Find handles by name |
| searchPosts | BLOCKED (403) | — | Use searchActors + getAuthorFeed workaround |
### Mastodon ✅✅✅ (FULLY OPEN)
| Method | Endpoint | Auth | Returns |
|--------|----------|------|---------|
| Account lookup | {instance}/api/v1/accounts/lookup?acct={user} | None | Full profile |
| Account statuses | {instance}/api/v1/accounts/{id}/statuses | None | All posts |
| Search | {instance}/api/v2/search?q={query}&type=accounts | None | Account search |
| WebFinger | {instance}/.well-known/webfinger?resource=acct:{user}@{instance} | None | Identity resolution |
| Trending | {instance}/api/v1/trends/tags | None | Trending content |
Key instances: mastodon.social, hachyderm.io, sigmoid.social
### Instagram ✅✅ (CRACKED)
| Method | Endpoint | Auth | Returns |
|--------|----------|------|---------|
| Private Web API | i.instagram.com/api/v1/users/web_profile_info/ | Mobile UA + x-ig-app-id: 936619743392459 | Profile + 12 posts + captions + CDN URLs |
| oEmbed | instagram.com/api/v1/oembed/ | None | Caption + author for individual posts |
| Pixwox | web_extract pixwox.com/profile/{user} | None | 12+ posts, engagement |
| SocialBlade | web_extract socialblade.com/instagram/user/{user} | None | Analytics, follower trends |
| CDN images | scontent-*.cdninstagram.com URLs from API | None | Full-res images → vision_analyze |
| Google index | web_search site:instagram.com | None | Bio, follower count, captions |
### GitHub ✅✅
| Method | Endpoint | Auth | Returns |
|--------|----------|------|---------|
| REST API | api.github.com/users/{user} | None (60 req/hr) | Profile, repos, events, gists |
| Profile README | github.com/{user}/{user} | None | Self-description (voice gold) |
### Reddit ✅✅
| Method | Endpoint | Auth | Returns |
|--------|----------|------|---------|
| JSON API | reddit.com/user/{user}.json | User-Agent header required | Comments, posts, scores |
| Search | reddit.com/r/{sub}/search.json | User-Agent header | Subreddit-specific search |
## TIER 2 — Good Data, Reliable Access
### Facebook ✅✅ (CRACKED — Googlebot UA trick)
| Method | Endpoint | Returns |
|--------|----------|---------|
| Googlebot UA (BEST) | curl facebook.com/{page} with Googlebot UA | OG tags: name, bio/about, likes count (e.g. 121M for zuck), talking_about count, og:image, profile pic |
| Page Plugin embed | plugins/page.php?href=...&tabs=timeline | Name, follower count, numeric page_id |
| Graph /picture | graph.facebook.com/v19.0/{page}/picture?redirect=false | Direct CDN profile pic URL (no auth) |
| web_search | site:facebook.com {name} | Profile snippets from Google index |
| Script: scripts/facebook_api.py — combines all 3 methods |
| NOTE: Works for PUBLIC Pages (businesses, public figures, orgs). Personal profiles behind privacy settings are not accessible. |
| Tested: zuck (121M likes), NVIDIA, Meta, CocaCola, BillGates, BarackObama |
### Threads (Meta) ✅✅ (CRACKED — OG tags DO exist)
| Method | Endpoint | Returns |
|--------|----------|---------|
| Profile OG tags (BEST) | curl -L threads.com/@{user} (NOTE: .com not .net — .net 301 redirects) | display_name, follower_count (e.g. "5.5M"), thread_count, bio, profile_picture_url |
| Post OG tags | curl -L threads.com/@{user}/post/{shortcode} | Full post text, author name, image URL |
| WebFinger | threads.net/.well-known/webfinger?resource=acct:{user}@threads.net | ActivityPub ID, profile URL (works for federated users) |
| IMPORTANT: threads.NET redirects to threads.COM — always use -L flag or go directly to .com |
| Post discovery | web_search site:threads.net @{user} | Find post URLs to then fetch |
| Script: scripts/threads_api.py — profile + post + webfinger extraction |
| Previous test was WRONG about "no OG tags" — they're there, you just need standard curl |
| Tested: zuck (5.5M followers), mosseri, nvidia |
### Medium ✅✅
| Method | Returns |
|--------|---------|
| RSS feed: medium.com/feed/@{user} (curl) | FULL article text, tags, dates — NO AUTH |
| web_extract on profile | Bio, follower count, article list, themes |
| web_extract on articles | Full content (paywall may truncate non-members) |
### Quora ✅✅
| Method | Returns |
|--------|---------|
| web_extract on profile | Bio, credentials, Q&A with direct quotes |
| web_search site:quora.com | Finds profiles and specific answers |
| VOICE VALUE: Opinions in own words, analogies, intellectual identity |
### Goodreads ✅✅ (HIDDEN GEM)
| Method | Returns |
|--------|---------|
| web_extract on user profile | Favorites, reviews in own voice, social graph, reading history |
| web_extract on author page | Bio, books, ratings, notable quotes |
| VOICE VALUE: "You are what you read" — intellectual identity fingerprint |
| Example: Karpathy's Goodreads reveals gaming passion, favorite authors (Feynman, Clarke) |
### Google Scholar ✅✅
| Method | Returns |
|--------|---------|
| web_search + web_extract on profile | Citations, h-index, top papers, co-authors |
| Semantic Scholar API via web_extract | Paper list, citation counts, author ID |
| Endpoint: api.semanticscholar.org/graph/v1/author/search?query={name} |
### Product Hunt ✅
| Method | Returns |
|--------|---------|
| web_extract on producthunt.com/@{user} | Bio, launch history, forum activity |
### HackerNews ✅
| Method | Returns |
|--------|---------|
| Algolia API: hn.algolia.com/api/v1/search?query={name}&tags=comment | Comments, mentions |
### Podcast Transcripts ✅✅✅ (HIGHEST VOICE VALUE)
| Source | Method |
|--------|--------|
| Lex Fridman | web_extract on lexfridman.com/.../transcript |
| Tyler Cowen | web_extract on conversationswithtyler.com |
| TED Talks | web_extract on ted.com/.../transcript |
| Sequoia | web_extract on sequoiacap.com/podcast |
| Discovery: web_search "{name} podcast transcript interview" |
### News/Blogs ✅✅
| Source | Method |
|--------|--------|
| TechCrunch, Wired, Verge, Ars | web_extract — full articles |
| Personal blogs | web_extract — longform self-expression |
| Substacks | web_extract — essays and comments |
| Wayback Machine | Works for blog archives (not Twitter) |
## TIER 3 — Limited / Conditional
### TikTok ✅✅ (FULL ACCESS)
| Method | Returns |
|--------|---------|
| HTML profile scraping | Parse __UNIVERSAL_DATA_FOR_REHYDRATION__ JSON at path __DEFAULT_SCOPE__.webapp.user-detail.userInfo.statsV2 → username, bio, followerCount, followingCount, heartCount, videoCount. Use statsV2 not stats for large numbers. |
| oEmbed per video | curl tiktok.com/oembed?url={video_url} → caption, author, thumbnail. No auth. |
| tikwm.com API | tikwm.com/api/user/info?unique_id={user} → full user stats. tikwm.com/api/?url={video_url} → play count, likes, comments, shares, duration. |
| HTML video scraping | tiktok.com/@{user}/video/{id} → parse __UNIVERSAL_DATA → webapp.video-detail → full video data with description, hashtags, engagement. |
| SocialBlade | web_extract socialblade.com/tiktok/user/{user} → followers, likes, growth trends. |
| Video discovery | web_search("site:tiktok.com/@{user}/video") → recent video URLs → scrape each |
| Tested: khaby.lame (160.5M), charlidamelio (156.7M), mrbeast (124.7M) |
### Spotify ✅ (podcasters only)
| Method | Returns |
|--------|---------|
| web_extract on show page | Episode listings with guests, topics, durations |
### Stack Overflow ✅
| Method | Returns |
|--------|---------|
| web_extract on profile | Reputation, tags, top answers, bio |
### Crunchbase ✅ (executives/founders only)
| Method | Returns |
|--------|---------|
| web_extract on crunchbase.com/person/{slug} | Full career history, education, investments, board positions |
### LinkedIn ⚠️ (indirect only)
| Method | Returns |
|--------|---------|
| web_search site:linkedin.com/in | Name, headline, company, location from snippets |
| Crunchbase | Full career history (better than LinkedIn for execs) |
| Corporate press pages | Official professional bios |
| RocketReach/SignalHire snippets | Title confirmation from web_search |
## TIER 4 — Blocked / Dead
| Platform | Status |
|----------|--------|
| LinkedIn direct | BLOCKED (web_extract domain blocked) |
| Discord | WALLED (not publicly indexable) |
| Telegram t.me | BLOCKED in some environments |
| Threads Official API | AUTH REQUIRED (graph.threads.net needs OAuth) |
| Threads ActivityPub outbox | 404 for all tested users |
| Instagram direct | BLOCKED (use Private API instead) |
| Most Nitter instances | DEAD (only nitter.cz works, but UNRELIABLE — see note) |
| Google Cache of Twitter | EMPTY |
| Wayback for tweets | USELESS (JS rendering) |
| Twitter Syndication API | RATE LIMITED |
| Archive.today | 429 + CAPTCHA |
| imginn/picuki/dumpoir/gramhir | 403 |
| Facebook Graph API | AUTH REQUIRED |
## Quick Reference: Research Pipeline by Person Type
### Tech Founder/CEO
X API → Bluesky → GitHub README → Crunchbase → Podcast transcripts → Medium RSS → HN → Product Hunt → LinkedIn snippets → News profiles
### AI Researcher
X API → Bluesky → Google Scholar → Semantic Scholar → arXiv → GitHub → Podcast transcripts → Blog/Substack → Reddit → Mastodon (sigmoid.social)
### Public Figure / Politician
X API → Facebook OG → Instagram API → YouTube → Podcast transcripts → News profiles → Quora → Goodreads → Wikipedia
### Content Creator
X API → Instagram API → TikTok → YouTube → Twitch → Podcast → Medium → Reddit → Bluesky → Threads OG
### Academic
Google Scholar → Semantic Scholar → University page → Conference talks → Podcast transcripts → Mastodon → Blog → GitHub → Reddit → HN
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
"""
REHOBOAM Database Layer
SQLite setup, migrations, and query helpers.
"""
import sqlite3
import os
from pathlib import Path
from datetime import datetime
DB_DIR = Path.home() / ".hermes" / "rehoboam" / "db"
MAIN_DB = DB_DIR / "rehoboam.db"
SCHEMA_VERSION = 1
SCHEMA_SQL = """
-- Core tables
CREATE TABLE IF NOT EXISTS profiles (
handle TEXT PRIMARY KEY,
platform TEXT NOT NULL,
display_name TEXT,
last_updated TEXT NOT NULL,
staleness TEXT NOT NULL,
profile_path TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS simulations (
sim_id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
scenario TEXT NOT NULL,
participant_count INTEGER,
duration_sec REAL,
model_used TEXT,
config_path TEXT,
output_path TEXT
);
CREATE TABLE IF NOT EXISTS sim_participants (
sim_id TEXT REFERENCES simulations(sim_id),
handle TEXT REFERENCES profiles(handle),
role TEXT,
PRIMARY KEY (sim_id, handle)
);
CREATE TABLE IF NOT EXISTS sim_dynamics (
sim_id TEXT REFERENCES simulations(sim_id),
handle TEXT,
post_count INTEGER,
word_count INTEGER,
avg_sentiment REAL,
dominance_score REAL,
agreement_score REAL,
controversy_score REAL,
ratio_score REAL,
influence_in_sim REAL,
PRIMARY KEY (sim_id, handle)
);
CREATE TABLE IF NOT EXISTS sim_interactions (
sim_id TEXT REFERENCES simulations(sim_id),
from_handle TEXT,
to_handle TEXT,
interaction_type TEXT,
count INTEGER,
avg_sentiment REAL,
PRIMARY KEY (sim_id, from_handle, to_handle, interaction_type)
);
CREATE TABLE IF NOT EXISTS predictions (
pred_id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
sim_id TEXT,
handle TEXT,
prediction_type TEXT,
prediction_text TEXT NOT NULL,
confidence REAL NOT NULL,
calibrated_confidence REAL,
timeframe_days INTEGER,
resolved_at TEXT,
outcome TEXT,
outcome_evidence TEXT,
accuracy_score REAL
);
CREATE TABLE IF NOT EXISTS social_edges (
from_handle TEXT,
to_handle TEXT,
relationship_type TEXT,
weight REAL,
first_observed TEXT,
last_observed TEXT,
observation_count INTEGER,
source TEXT,
PRIMARY KEY (from_handle, to_handle, relationship_type)
);
CREATE TABLE IF NOT EXISTS social_clusters (
cluster_id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
member_handles TEXT,
computed_at TEXT,
cohesion_score REAL
);
CREATE TABLE IF NOT EXISTS monitoring_events (
event_id TEXT PRIMARY KEY,
handle TEXT,
detected_at TEXT NOT NULL,
event_type TEXT,
description TEXT,
related_prediction_id TEXT,
severity TEXT,
acknowledged INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS audit_log (
log_id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
sim_id TEXT,
action TEXT NOT NULL,
handle TEXT,
details TEXT,
duration_sec REAL,
model_used TEXT,
token_count INTEGER,
error TEXT
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_predictions_handle ON predictions(handle);
CREATE INDEX IF NOT EXISTS idx_predictions_type ON predictions(prediction_type);
CREATE INDEX IF NOT EXISTS idx_predictions_unresolved ON predictions(outcome) WHERE outcome IS NULL;
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_sim ON audit_log(sim_id);
CREATE INDEX IF NOT EXISTS idx_social_edges_from ON social_edges(from_handle);
CREATE INDEX IF NOT EXISTS idx_social_edges_to ON social_edges(to_handle);
CREATE INDEX IF NOT EXISTS idx_monitoring_handle ON monitoring_events(handle);
CREATE INDEX IF NOT EXISTS idx_monitoring_unack ON monitoring_events(acknowledged) WHERE acknowledged = 0;
-- Schema version tracking
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
def init_db() -> sqlite3.Connection:
"""Initialize the database, creating tables if needed."""
DB_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(MAIN_DB))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript(SCHEMA_SQL)
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION))
)
conn.commit()
return conn
def get_db() -> sqlite3.Connection:
"""Get a database connection, initializing if needed."""
if not MAIN_DB.exists():
return init_db()
conn = sqlite3.connect(str(MAIN_DB))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
return conn
def log_audit(conn: sqlite3.Connection, action: str, handle: str = None,
sim_id: str = None, details: str = None, duration_sec: float = None,
model_used: str = None, token_count: int = None, error: str = None):
"""Write an entry to the audit log."""
from schemas import gen_id
conn.execute(
"""INSERT INTO audit_log
(log_id, timestamp, sim_id, action, handle, details, duration_sec, model_used, token_count, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(gen_id("log_"), datetime.utcnow().isoformat() + "Z", sim_id, action,
handle, details, duration_sec, model_used, token_count, error)
)
conn.commit()
# -- Query Helpers --
def get_prediction_accuracy(conn: sqlite3.Connection, prediction_type: str = None) -> dict:
"""Get prediction accuracy statistics."""
query = """
SELECT prediction_type,
COUNT(*) as total,
SUM(CASE WHEN outcome='correct' THEN 1 ELSE 0 END) as correct,
SUM(CASE WHEN outcome='partially_correct' THEN 1 ELSE 0 END) as partial,
SUM(CASE WHEN outcome='incorrect' THEN 1 ELSE 0 END) as incorrect,
AVG(confidence) as avg_confidence,
AVG(CASE WHEN outcome='correct' THEN 1.0
WHEN outcome='partially_correct' THEN 0.5
ELSE 0.0 END) as accuracy
FROM predictions WHERE outcome IS NOT NULL
"""
params = []
if prediction_type:
query += " AND prediction_type = ?"
params.append(prediction_type)
query += " GROUP BY prediction_type"
return [dict(row) for row in conn.execute(query, params).fetchall()]
def get_open_predictions(conn: sqlite3.Connection, handle: str = None) -> list:
"""Get unresolved predictions."""
query = "SELECT * FROM predictions WHERE outcome IS NULL"
params = []
if handle:
query += " AND handle = ?"
params.append(handle)
query += " ORDER BY created_at DESC"
return [dict(row) for row in conn.execute(query, params).fetchall()]
def get_social_neighborhood(conn: sqlite3.Connection, handle: str, depth: int = 1) -> list:
"""Get a person's social graph neighborhood."""
query = """
SELECT from_handle, to_handle, relationship_type, weight
FROM social_edges
WHERE from_handle = ? OR to_handle = ?
ORDER BY weight DESC
"""
return [dict(row) for row in conn.execute(query, (handle, handle)).fetchall()]
def get_unread_alerts(conn: sqlite3.Connection) -> list:
"""Get unacknowledged monitoring alerts."""
query = """
SELECT * FROM monitoring_events
WHERE acknowledged = 0
ORDER BY detected_at DESC
"""
return [dict(row) for row in conn.execute(query).fetchall()]
if __name__ == "__main__":
conn = init_db()
print(f"Database initialized at {MAIN_DB}")
conn.close()
@@ -0,0 +1,216 @@
"""
REHOBOAM Data Schemas
Pydantic models for all JSON data structures used in the system.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime
import json
import uuid
def gen_id(prefix: str = "") -> str:
return f"{prefix}{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
@dataclass
class OceanScores:
openness: float = 0.5
conscientiousness: float = 0.5
extraversion: float = 0.5
agreeableness: float = 0.5
neuroticism: float = 0.5
@dataclass
class DarkTriad:
narcissism: float = 0.0
machiavellianism: float = 0.0
psychopathy: float = 0.0
@dataclass
class MoralFoundations:
care: float = 0.5
fairness: float = 0.5
loyalty: float = 0.5
authority: float = 0.5
sanctity: float = 0.5
liberty: float = 0.5
@dataclass
class Psychometrics:
ocean: OceanScores = field(default_factory=OceanScores)
mbti_estimate: str = ""
dark_triad: DarkTriad = field(default_factory=DarkTriad)
moral_foundations: MoralFoundations = field(default_factory=MoralFoundations)
confidence: float = 0.0
sample_size: int = 0
@dataclass
class VoiceFingerprint:
vocabulary_tier: str = ""
avg_sentence_length: float = 0.0
exclamation_rate: float = 0.0
question_rate: float = 0.0
emoji_rate: float = 0.0
slang_index: float = 0.0
formality_score: float = 0.5
humor_style: str = ""
signature_phrases: list[str] = field(default_factory=list)
topics_vocabulary: dict[str, float] = field(default_factory=dict)
cadence_pattern: str = ""
@dataclass
class Stance:
position: str = ""
intensity: float = 0.0
last_seen: str = ""
@dataclass
class Influence:
score: float = 0.0
reach: str = "micro"
engagement_rate: float = 0.0
amplification_power: float = 0.0
thought_leadership_domains: list[str] = field(default_factory=list)
@dataclass
class PostingPatterns:
avg_posts_per_day: float = 0.0
peak_hours_utc: list[int] = field(default_factory=list)
weekend_ratio: float = 0.5
reply_ratio: float = 0.0
repost_ratio: float = 0.0
thread_frequency: float = 0.0
controversy_rate: float = 0.0
@dataclass
class Relationships:
allies: list[str] = field(default_factory=list)
rivals: list[str] = field(default_factory=list)
frequent_interactions: list[str] = field(default_factory=list)
mentioned_by_frequently: list[str] = field(default_factory=list)
@dataclass
class ProfileMeta:
data_sources: list[str] = field(default_factory=list)
computation_time_sec: float = 0.0
model_used: str = ""
last_full_rebuild: str = ""
last_incremental: str = ""
@dataclass
class Identity:
bio: str = ""
location: str = ""
verified: bool = False
follower_count: int = 0
following_count: int = 0
account_created: str = ""
@dataclass
class Profile:
schema_version: str = "7.0"
handle: str = ""
platform: str = "x"
display_name: str = ""
created_at: str = ""
last_updated: str = ""
update_count: int = 0
staleness_score: float = 1.0
identity: Identity = field(default_factory=Identity)
psychometrics: Psychometrics = field(default_factory=Psychometrics)
voice_fingerprint: VoiceFingerprint = field(default_factory=VoiceFingerprint)
stances: dict[str, Stance] = field(default_factory=dict)
community_membership: list[str] = field(default_factory=list)
influence: Influence = field(default_factory=Influence)
posting_patterns: PostingPatterns = field(default_factory=PostingPatterns)
relationships: Relationships = field(default_factory=Relationships)
star_thread_ref: str = "star_thread.json"
raw_data_refs: list[str] = field(default_factory=list)
_meta: ProfileMeta = field(default_factory=ProfileMeta)
def to_dict(self) -> dict:
"""Recursively convert to dict for JSON serialization."""
import dataclasses
def _convert(obj):
if dataclasses.is_dataclass(obj):
return {k: _convert(v) for k, v in dataclasses.asdict(obj).items()}
elif isinstance(obj, list):
return [_convert(i) for i in obj]
elif isinstance(obj, dict):
return {k: _convert(v) for k, v in obj.items()}
return obj
return _convert(self)
def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent)
@dataclass
class StarThread:
handle: str = ""
computed_at: str = ""
based_on_profile_version: str = ""
thread_version: int = 1
core_compression: str = ""
key_drives: list[str] = field(default_factory=list)
predictive_axioms: list[str] = field(default_factory=list)
voice_template: dict = field(default_factory=dict)
anti_slop_markers: list[str] = field(default_factory=list)
_meta: dict = field(default_factory=dict)
@dataclass
class Prediction:
pred_id: str = ""
created_at: str = ""
sim_id: str = ""
handle: str = ""
prediction_type: str = "" # statement, career, alliance, content, network_reaction
prediction_text: str = ""
confidence: float = 0.5
calibrated_confidence: float = 0.5
timeframe_days: int = 30
resolved_at: Optional[str] = None
outcome: Optional[str] = None # correct, partially_correct, incorrect
outcome_evidence: Optional[str] = None
accuracy_score: Optional[float] = None
@dataclass
class WatchConfig:
watch_id: str = ""
handle: str = ""
platform: str = "x"
enabled: bool = True
check_interval_minutes: int = 120
watch_for: list[dict] = field(default_factory=list)
alert_severity_minimum: str = "notable"
created_at: str = ""
@dataclass
class PopulationDefinition:
group_id: str = ""
name: str = ""
description: str = ""
created_at: str = ""
last_updated: str = ""
explicit_members: list[str] = field(default_factory=list)
criteria: dict = field(default_factory=dict)
resolved_members: list[str] = field(default_factory=list)
sampling_strategy: str = "representative"
default_sample_size: int = 12
@@ -0,0 +1,280 @@
"""
REHOBOAM Storage Layer
Directory management, profile I/O, index maintenance.
"""
import json
import shutil
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional
BASE_DIR = Path.home() / ".hermes" / "rehoboam"
PROFILES_DIR = BASE_DIR / "profiles"
POPULATIONS_DIR = BASE_DIR / "populations"
SIMULATIONS_DIR = BASE_DIR / "simulations"
MONITORING_DIR = BASE_DIR / "monitoring"
CONFIG_DIR = BASE_DIR / "config"
def init_storage():
"""Create all required directories."""
for d in [PROFILES_DIR, POPULATIONS_DIR, SIMULATIONS_DIR,
MONITORING_DIR, MONITORING_DIR / "alerts", CONFIG_DIR,
BASE_DIR / "db"]:
d.mkdir(parents=True, exist_ok=True)
# Create default configs if they don't exist
staleness_path = CONFIG_DIR / "staleness_policy.json"
if not staleness_path.exists():
staleness_path.write_text(json.dumps({
"thresholds": {
"fresh": {"max_age_hours": 72},
"stale": {"max_age_hours": 336},
"expired": {"max_age_hours": 2160},
"archived": {"max_age_hours": 8760}
},
"per_field_decay": {
"psychometrics": {"half_life_days": 180},
"stances": {"half_life_days": 30},
"posting_patterns": {"half_life_days": 60},
"relationships": {"half_life_days": 45},
"influence": {"half_life_days": 90},
"voice_fingerprint": {"half_life_days": 365}
},
"auto_refresh_on_simulation": True,
"auto_refresh_threshold": "stale"
}, indent=2))
config_path = CONFIG_DIR / "rehoboam.json"
if not config_path.exists():
config_path.write_text(json.dumps({
"version": "7.0",
"default_model": "claude-opus-4-20250514",
"max_thread_age_days": 30,
"monitoring_enabled": False,
"auto_thread": True,
"auto_profile_update": True
}, indent=2))
# Create indexes if they don't exist
for idx_path in [PROFILES_DIR / "_index.json", POPULATIONS_DIR / "_index.json",
SIMULATIONS_DIR / "_index.json"]:
if not idx_path.exists():
idx_path.write_text("{}")
def normalize_handle(handle: str) -> str:
"""Normalize a handle to a filesystem-safe directory name."""
h = handle.lstrip("@").lower().strip()
# Replace characters that are problematic in filenames
return h.replace("/", "_").replace("\\", "_")
# -- Profile I/O --
def get_profile_dir(handle: str) -> Path:
return PROFILES_DIR / normalize_handle(handle)
def profile_exists(handle: str) -> bool:
return (get_profile_dir(handle) / "profile.json").exists()
def load_profile(handle: str) -> Optional[dict]:
path = get_profile_dir(handle) / "profile.json"
if path.exists():
return json.loads(path.read_text())
return None
def save_profile(handle: str, profile: dict, snapshot: bool = True):
"""Save a profile, optionally snapshotting the old one."""
pdir = get_profile_dir(handle)
pdir.mkdir(parents=True, exist_ok=True)
(pdir / "history").mkdir(exist_ok=True)
(pdir / "raw").mkdir(exist_ok=True)
(pdir / "predictions").mkdir(exist_ok=True)
profile_path = pdir / "profile.json"
# Snapshot old profile before overwriting
if snapshot and profile_path.exists():
old = json.loads(profile_path.read_text())
ts = old.get("last_updated", datetime.utcnow().isoformat()).replace(":", "-")
snapshot_path = pdir / "history" / f"profile_{ts[:10]}.json"
shutil.copy2(profile_path, snapshot_path)
profile_path.write_text(json.dumps(profile, indent=2))
_update_profile_index(handle, profile)
def _update_profile_index(handle: str, profile: dict):
idx_path = PROFILES_DIR / "_index.json"
idx = json.loads(idx_path.read_text()) if idx_path.exists() else {}
idx[normalize_handle(handle)] = {
"platform": profile.get("platform", "x"),
"last_updated": profile.get("last_updated", ""),
"staleness": compute_staleness(profile.get("last_updated", "")),
"has_star_thread": (get_profile_dir(handle) / "star_thread.json").exists(),
"simulation_count": idx.get(normalize_handle(handle), {}).get("simulation_count", 0),
"display_name": profile.get("display_name", "")
}
idx_path.write_text(json.dumps(idx, indent=2))
# -- Star Thread I/O --
def load_star_thread(handle: str) -> Optional[dict]:
path = get_profile_dir(handle) / "star_thread.json"
if path.exists():
return json.loads(path.read_text())
return None
def save_star_thread(handle: str, thread: dict):
path = get_profile_dir(handle) / "star_thread.json"
get_profile_dir(handle).mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(thread, indent=2))
# Update index to reflect thread existence
idx_path = PROFILES_DIR / "_index.json"
if idx_path.exists():
idx = json.loads(idx_path.read_text())
key = normalize_handle(handle)
if key in idx:
idx[key]["has_star_thread"] = True
idx_path.write_text(json.dumps(idx, indent=2))
# -- Staleness --
def compute_staleness(last_updated: str) -> str:
"""Determine staleness level from a timestamp string."""
if not last_updated:
return "expired"
try:
dt = datetime.fromisoformat(last_updated.rstrip("Z"))
except ValueError:
return "expired"
age = datetime.utcnow() - dt
hours = age.total_seconds() / 3600
policy = _load_staleness_policy()
thresholds = policy.get("thresholds", {})
if hours <= thresholds.get("fresh", {}).get("max_age_hours", 72):
return "fresh"
elif hours <= thresholds.get("stale", {}).get("max_age_hours", 336):
return "stale"
elif hours <= thresholds.get("expired", {}).get("max_age_hours", 2160):
return "expired"
else:
return "archived"
def _load_staleness_policy() -> dict:
path = CONFIG_DIR / "staleness_policy.json"
if path.exists():
return json.loads(path.read_text())
return {"thresholds": {"fresh": {"max_age_hours": 72}, "stale": {"max_age_hours": 336},
"expired": {"max_age_hours": 2160}, "archived": {"max_age_hours": 8760}}}
def needs_thread_recompute(handle: str) -> bool:
"""Check if a star thread needs recomputation."""
thread = load_star_thread(handle)
if thread is None:
return True
profile = load_profile(handle)
if profile is None:
return True
# Thread is stale if profile was updated after thread was computed
thread_time = thread.get("based_on_profile_version", "")
profile_time = profile.get("last_updated", "")
if thread_time < profile_time:
return True
# Thread is stale if older than max_thread_age_days
config = json.loads((CONFIG_DIR / "rehoboam.json").read_text()) if (CONFIG_DIR / "rehoboam.json").exists() else {}
max_age = config.get("max_thread_age_days", 30)
try:
computed = datetime.fromisoformat(thread.get("computed_at", "").rstrip("Z"))
if (datetime.utcnow() - computed).days > max_age:
return True
except ValueError:
return True
return False
# -- Simulation I/O --
def save_simulation(sim_id: str, config: dict, output: dict, analytics: dict, audit: dict):
sdir = SIMULATIONS_DIR / sim_id
sdir.mkdir(parents=True, exist_ok=True)
(sdir / "config.json").write_text(json.dumps(config, indent=2))
(sdir / "output.json").write_text(json.dumps(output, indent=2))
(sdir / "analytics.json").write_text(json.dumps(analytics, indent=2))
(sdir / "audit.json").write_text(json.dumps(audit, indent=2))
# Update index
idx_path = SIMULATIONS_DIR / "_index.json"
idx = json.loads(idx_path.read_text()) if idx_path.exists() else {}
idx[sim_id] = {
"created_at": config.get("created_at", datetime.utcnow().isoformat() + "Z"),
"scenario": config.get("scenario", ""),
"participant_count": len(config.get("participants", [])),
}
idx_path.write_text(json.dumps(idx, indent=2))
# -- Population I/O --
def save_population(group_id: str, definition: dict, aggregate: dict = None):
pdir = POPULATIONS_DIR / group_id
pdir.mkdir(parents=True, exist_ok=True)
(pdir / "history").mkdir(exist_ok=True)
(pdir / "definition.json").write_text(json.dumps(definition, indent=2))
if aggregate:
(pdir / "aggregate.json").write_text(json.dumps(aggregate, indent=2))
idx_path = POPULATIONS_DIR / "_index.json"
idx = json.loads(idx_path.read_text()) if idx_path.exists() else {}
idx[group_id] = {
"name": definition.get("name", group_id),
"member_count": len(definition.get("resolved_members", definition.get("explicit_members", []))),
"last_updated": definition.get("last_updated", "")
}
idx_path.write_text(json.dumps(idx, indent=2))
def load_population(group_id: str) -> Optional[dict]:
path = POPULATIONS_DIR / group_id / "definition.json"
if path.exists():
return json.loads(path.read_text())
return None
# -- Listing --
def list_profiles() -> dict:
idx_path = PROFILES_DIR / "_index.json"
return json.loads(idx_path.read_text()) if idx_path.exists() else {}
def list_populations() -> dict:
idx_path = POPULATIONS_DIR / "_index.json"
return json.loads(idx_path.read_text()) if idx_path.exists() else {}
def list_simulations() -> dict:
idx_path = SIMULATIONS_DIR / "_index.json"
return json.loads(idx_path.read_text()) if idx_path.exists() else {}
if __name__ == "__main__":
init_storage()
print(f"Storage initialized at {BASE_DIR}")
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Facebook Page/Profile Data Extractor
Uses multiple techniques to extract public Facebook data without authentication:
1. Googlebot UA for OG meta tags (name, description, likes, talking_about, bio, og:image)
2. Graph API /picture endpoint for profile photos (pages only)
3. Page Plugin embed for follower counts and page IDs
"""
import subprocess
import json
import re
import html
import sys
GOOGLEBOT_UA = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
def curl_get(url, ua=None):
"""Fetch URL with curl"""
cmd = ['curl', '-s', '-L', '--max-time', '15']
if ua:
cmd += ['-H', f'User-Agent: {ua}']
cmd.append(url)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
return result.stdout
def extract_og_data(username):
"""Extract OG meta tags using Googlebot UA"""
content = curl_get(f'https://www.facebook.com/{username}', ua=GOOGLEBOT_UA)
data = {}
# Extract OG tags
og_title = re.search(r'og:title"\s*content="([^"]*)"', content)
if og_title:
data['name'] = html.unescape(og_title.group(1))
og_desc = re.search(r'og:description"\s*content="([^"]*)"', content)
if og_desc:
desc = html.unescape(og_desc.group(1))
data['raw_description'] = desc
# Parse likes count
likes_match = re.search(r'([\d,]+)\s+likes?', desc)
if likes_match:
data['likes'] = likes_match.group(1)
# Parse talking about
talking_match = re.search(r'([\d,]+)\s+talking about this', desc)
if talking_match:
data['talking_about'] = talking_match.group(1)
# Extract bio (text after the "talking about this." part)
bio_match = re.search(r'talking about this\.\s*(.+)', desc)
if bio_match:
data['bio'] = bio_match.group(1)
og_image = re.search(r'og:image"\s*content="([^"]*)"', content)
if og_image:
data['og_image'] = html.unescape(og_image.group(1))
return data
def extract_plugin_data(username):
"""Extract data from Page Plugin embed"""
content = curl_get(f'https://www.facebook.com/plugins/page.php?href=https://www.facebook.com/{username}&tabs=timeline&width=500&height=600')
data = {}
# Page name from title attribute
name_match = re.search(r'class="_1drp _5lv6" title="([^"]*)"', content)
if name_match:
data['plugin_name'] = html.unescape(name_match.group(1))
# Follower count
followers_match = re.search(r'([\d,]+)\s+followers', content)
if followers_match:
data['followers'] = followers_match.group(1)
# Page ID
pageid_match = re.search(r'"pageID":"(\d+)"', content)
if pageid_match:
data['page_id'] = pageid_match.group(1)
return data
def extract_profile_picture(username):
"""Get profile picture via Graph API"""
content = curl_get(f'https://graph.facebook.com/v19.0/{username}/picture?redirect=false&width=400&height=400')
try:
d = json.loads(content)
if 'data' in d and not d['data'].get('is_silhouette', True):
return d['data']['url']
except:
pass
return None
def get_facebook_data(username):
"""Combine all extraction methods"""
result = {'username': username}
# Method 1: OG tags (best for bio, likes, talking_about)
og = extract_og_data(username)
result.update(og)
# Method 2: Plugin (best for followers, page_id)
plugin = extract_plugin_data(username)
result.update(plugin)
# Method 3: Graph API picture (pages only)
pic = extract_profile_picture(username)
if pic:
result['profile_picture'] = pic
# Also try by page_id for picture if username didn't work
if not pic and 'page_id' in result:
pic2 = extract_profile_picture(result['page_id'])
if pic2:
result['profile_picture'] = pic2
return result
if __name__ == '__main__':
targets = sys.argv[1:] if len(sys.argv) > 1 else ['zuck', 'NVIDIA', 'Meta', 'CocaCola']
for target in targets:
print(f"{'='*60}")
print(f"Facebook Profile: {target}")
print(f"{'='*60}")
data = get_facebook_data(target)
for k, v in data.items():
if k == 'raw_description':
continue # Skip raw, we show parsed fields
val = str(v)
if len(val) > 120:
val = val[:120] + '...'
print(f" {k}: {val}")
print()
@@ -0,0 +1,595 @@
"""
Hermes Simulator Intelligence Gathering Pipeline v2
Full-spectrum OSINT research engine for personality modeling.
Searches text, extracts content, browses live pages, analyzes
images with vision, and cross-references across platforms.
Run via execute_code. The agent adapts searches based on findings.
"""
from hermes_tools import web_search, web_extract, terminal
import json
import time
import urllib.parse
# ═══════════════════════════════════════════════════════════════
# CONFIGURATION
# ═══════════════════════════════════════════════════════════════
AGGREGATOR_SITES = [
"buttondown.com/ainews",
"news.smol.ai",
"techmeme.com",
"latent.space",
]
# Verified working fallback data sources (tested April 2026)
# Priority order: X API > nitter.cz > ThreadReaderApp > GitHub > Reddit > HN
FALLBACK_SOURCES = {
"nitter": "https://nitter.cz/{handle}", # web_extract — full timeline
"threadreader": "https://threadreaderapp.com/user/{handle}", # web_extract — historical threads
"github_profile": "https://api.github.com/users/{handle}", # curl — profile + README
"github_events": "https://api.github.com/users/{handle}/events", # curl — recent activity
"reddit_user": "https://www.reddit.com/user/{handle}.json", # curl w/ User-Agent
"reddit_comments": "https://www.reddit.com/user/{handle}/comments.json",
"hn_search": "https://hn.algolia.com/api/v1/search?query={handle}&tags=comment",
}
# CONFIRMED BLOCKED (don't waste calls on these):
# - LinkedIn (web_extract blocked, browser auth wall)
# - Instagram viewers (imginn, picuki, dumpoir, gramhir — all 403)
# - Most nitter instances (dead or 403, ONLY nitter.cz works via web_extract)
# - Wayback Machine for tweets (sparse, no JS content)
# - Google Cache of Twitter (empty)
# - Archive.today (429 + CAPTCHA)
# - Twitter Syndication API (rate limited)
AI_SUBREDDITS = [
"LocalLLaMA", "MachineLearning", "singularity",
"ChatGPT", "ClaudeAI", "OpenAI", "StableDiffusion",
]
PLATFORMS = ["twitter", "instagram", "linkedin", "github", "reddit", "youtube"]
# ═══════════════════════════════════════════════════════════════
# HELPER: safe web_search with validation
# ═══════════════════════════════════════════════════════════════
def _safe_web_search(query: str, limit: int = 5) -> list:
"""Run web_search and return results list, with validation."""
r = web_search(query, limit=limit)
if not isinstance(r, dict) or "data" not in r:
print(f" [WARNING] web_search returned no 'data' key for query: {query[:80]}")
return []
data = r.get("data", {})
if not isinstance(data, dict):
return []
return data.get("web", []) or []
# ═══════════════════════════════════════════════════════════════
# CORE SEARCH FUNCTIONS
# ═══════════════════════════════════════════════════════════════
def search_identity(handle: str) -> dict:
"""Establish who they are across the internet."""
results = {}
results["twitter_identity"] = _safe_web_search(f"@{handle} twitter bio role company", limit=5)
results["general_identity"] = _safe_web_search(f"{handle} known for", limit=5)
return results
def search_voice(handle: str) -> dict:
"""How do they actually talk/write."""
results = {}
results["takes"] = _safe_web_search(f"{handle} twitter hot takes opinions", limit=5)
for agg in AGGREGATOR_SITES[:2]:
hits = _safe_web_search(f"site:{agg} {handle}", limit=3)
if hits:
# Use full domain as key, not split('.')[0]
results[f"agg_{agg}"] = hits
return results
def search_positions(handle: str, topics: list = None, domain: str = None) -> dict:
"""What are their known positions."""
results = {}
if topics:
for topic in topics[:3]:
results[f"topic_{topic}"] = _safe_web_search(f"{handle} {topic} opinion take", limit=5)
# Build controversy query — only add domain keywords if specified
controversy_query = f"{handle} debate disagree controversial"
if domain:
controversy_query += f" {domain}"
results["controversies"] = _safe_web_search(controversy_query, limit=5)
return results
def search_longform(handle: str, real_name: str = None, domain: str = None) -> dict:
"""Blogs, interviews, essays."""
results = {}
name = real_name or handle
blog_query = f"{name} blog substack essay"
interview_query = f"{name} interview podcast"
if domain:
blog_query += f" {domain}"
interview_query += f" {domain}"
results["blogs"] = _safe_web_search(blog_query, limit=5)
results["interviews"] = _safe_web_search(interview_query, limit=5)
return results
# ═══════════════════════════════════════════════════════════════
# CROSS-PLATFORM DISCOVERY
# ═══════════════════════════════════════════════════════════════
def discover_platforms(handle: str, real_name: str = None) -> dict:
"""Find someone across all platforms."""
name = real_name or handle
results = {}
# Instagram
results["instagram"] = _safe_web_search(f"{name} instagram OR site:instagram.com/{handle}", limit=5)
# LinkedIn
results["linkedin"] = _safe_web_search(f"{name} linkedin OR site:linkedin.com/in", limit=5)
# Reddit
results["reddit"] = _safe_web_search(f"{name} reddit account OR site:reddit.com/user", limit=5)
# GitHub
results["github"] = _safe_web_search(f"{handle} github OR site:github.com/{handle}", limit=5)
# YouTube
results["youtube"] = _safe_web_search(f"{name} youtube channel OR talk OR interview", limit=5)
# Personal site
results["personal_site"] = _safe_web_search(f"{name} personal website blog about", limit=5)
# Hacker News
results["hackernews"] = _safe_web_search(f"site:news.ycombinator.com {handle} OR {name}", limit=3)
return results
def discover_instagram(handle: str = None, real_name: str = None) -> dict:
"""Focused Instagram discovery."""
results = {}
name = real_name or handle
# Try to find their IG handle
results["ig_search"] = _safe_web_search(f"{name} instagram profile", limit=5)
# If we have a candidate IG URL, try to extract
ig_urls = []
for item in results.get("ig_search", []):
if not isinstance(item, dict):
continue
url = item.get("url", "")
if "instagram.com/" in url and "/p/" not in url:
ig_urls.append(url)
if ig_urls:
# Try to extract IG profile page
r = web_extract(urls=ig_urls[:1])
results["ig_profile"] = r.get("results", [])
return results
# ═══════════════════════════════════════════════════════════════
# VISUAL INTELLIGENCE
# ═══════════════════════════════════════════════════════════════
# NOTE: These functions use browser_* and vision_analyze which are
# NOT available in execute_code. They are called DIRECTLY by the
# agent after the execute_code research phase.
#
# The agent should:
# 1. Run this script via execute_code for text-based research
# 2. Then use browser/vision tools directly for visual research
#
# Visual research tasks for the agent:
#
# INSTAGRAM VISUAL:
# browser_navigate("https://www.instagram.com/{ig_handle}/")
# browser_vision(question="Describe this Instagram profile: bio, pic, grid, aesthetic, follower count")
# browser_get_images() # collect image URLs
# vision_analyze(image_url="{url}", question="Describe: setting, people, mood, style")
#
# PROFILE PIC ANALYSIS:
# vision_analyze(image_url="{pic_url}", question="Describe: appearance, clothing, setting, expression, professional vs casual")
#
# REVERSE IMAGE SEARCH (Yandex):
# # Upload to catbox if behind auth:
# terminal("curl -F 'reqtype=fileupload' -F 'fileToUpload=@{path}' https://catbox.moe/user/api.php")
# browser_navigate(f"https://yandex.com/images/search?rpt=imageview&url={encoded_url}")
#
# PAGE SCREENSHOT ANALYSIS:
# browser_vision(question="Read all text, usernames, post content, dates, engagement numbers")
# ═══════════════════════════════════════════════════════════════
# INTERACTION MAPPING
# ═══════════════════════════════════════════════════════════════
def search_interactions(handle: str, other_handles: list = None) -> dict:
"""How they interact with other simulation targets."""
results = {}
if other_handles:
for other in other_handles[:4]:
hits = _safe_web_search(f"{handle} {other} twitter interaction debate reply", limit=3)
if hits:
results[f"with_{other}"] = hits
return results
def search_social_graph(handle: str) -> dict:
"""Who do they interact with most? Allies and rivals."""
results = {}
results["frequent_interactions"] = _safe_web_search(f"@{handle} twitter reply thread conversation with", limit=5)
results["conflicts"] = _safe_web_search(f"@{handle} disagree argue beef ratio", limit=5)
results["allies"] = _safe_web_search(f"@{handle} agree support endorse recommend", limit=5)
return results
# ═══════════════════════════════════════════════════════════════
# DEEP EXTRACTION
# ═══════════════════════════════════════════════════════════════
def extract_content(urls: list) -> list:
"""Pull full content from high-value URLs."""
if not urls:
return []
r = web_extract(urls=urls[:3])
return r.get("results", [])
def extract_best_urls(findings: dict, max_urls: int = 5) -> list:
"""Find the most promising URLs in research findings for deep extraction."""
seen_urls = set() # URL deduplication
priority_domains = [
"substack.com", "medium.com", "blog", "essay",
"interview", "podcast", "youtube.com", "arxiv.org",
]
def score_url(url, desc):
score = 0
for domain in priority_domains:
if domain in url.lower() or domain in desc.lower():
score += 2
if any(w in desc.lower() for w in ["interview", "spoke", "told", "said", "wrote"]):
score += 1
return score
candidates = []
def collect(obj):
if isinstance(obj, list):
for item in obj:
if isinstance(item, dict):
url = item.get("url") or ""
desc = item.get("description") or item.get("text") or ""
if url and url not in seen_urls and not any(x in url for x in ["x.com", "twitter.com", "instagram.com"]):
seen_urls.add(url)
candidates.append((score_url(url, desc), url))
elif isinstance(obj, dict):
for v in obj.values():
collect(v)
collect(findings)
candidates.sort(key=lambda x: -x[0])
return [url for _, url in candidates[:max_urls]]
# ═══════════════════════════════════════════════════════════════
# MAIN PIPELINE
# ═══════════════════════════════════════════════════════════════
def research_person(handle: str, fidelity: int = 70,
topics: list = None,
other_handles: list = None,
real_name: str = None,
domain: str = None) -> dict:
"""
Full research pipeline for one person.
Returns dict with all findings organized by category.
Args:
handle: Twitter/X handle (without @)
fidelity: Research depth 0-100
topics: Specific topics to research
other_handles: Other people to check interactions with
real_name: Real name if different from handle
domain: Domain context (e.g., 'AI', 'politics', 'gaming').
When None, no domain keywords are added to searches.
When set, adds relevant domain keywords.
"""
print(f"\n{'='*60}")
print(f" RESEARCHING: @{handle} | Fidelity: {fidelity}%")
if domain:
print(f" Domain: {domain}")
print(f"{'='*60}")
findings = {"handle": handle, "fidelity": fidelity, "visual_tasks": []}
# ─── Phase 1: Identity (always) ───
print(f"\n [IDENTITY] Who are they...")
findings["identity"] = search_identity(handle)
if fidelity <= 30:
if topics:
findings["quick_topic"] = _safe_web_search(f"{handle} {topics[0]}", limit=3)
return findings
# ─── Phase 2: Voice (fidelity 31+) ───
print(f"\n [VOICE] How do they talk...")
findings["voice"] = search_voice(handle)
# ─── Phase 3: Positions (fidelity 31+) ───
print(f"\n [POSITIONS] What do they believe...")
findings["positions"] = search_positions(handle, topics, domain=domain)
if fidelity <= 50:
return findings
# ─── Phase 4: Cross-platform (fidelity 51+) ───
print(f"\n [PLATFORMS] Finding them everywhere...")
findings["platforms"] = discover_platforms(handle, real_name)
if fidelity <= 70:
return findings
# ─── Phase 5: Longform (fidelity 71+) ───
print(f"\n [LONGFORM] Blogs, interviews, essays...")
findings["longform"] = search_longform(handle, real_name, domain=domain)
# ─── Phase 6: Social graph (fidelity 71+) ───
print(f"\n [SOCIAL GRAPH] Who do they interact with...")
findings["social_graph"] = search_social_graph(handle)
# ─── Phase 7: Interaction mapping (fidelity 71+) ───
if other_handles:
print(f"\n [INTERACTIONS] With other targets: {other_handles}...")
findings["interactions"] = search_interactions(handle, other_handles)
# ─── Phase 8: Instagram deep dive (fidelity 80+) ───
if fidelity >= 80:
print(f"\n [INSTAGRAM] Visual identity...")
findings["instagram"] = discover_instagram(handle, real_name)
# Queue visual tasks for the agent to do after execute_code
findings["visual_tasks"].append({
"type": "instagram_profile",
"instruction": f"browser_navigate to Instagram profile, use browser_vision to analyze",
"handle": handle,
})
# ─── Phase 9: Deep extraction (fidelity 85+) ───
if fidelity >= 85:
print(f"\n [DEEP EXTRACT] Pulling longform content...")
best_urls = extract_best_urls(findings, max_urls=4)
if best_urls:
print(f" Extracting {len(best_urls)} URLs: {best_urls}")
findings["deep_extracts"] = extract_content(best_urls)
# ─── Phase 10: Profile pic analysis (fidelity 90+) ───
if fidelity >= 90:
findings["visual_tasks"].append({
"type": "profile_pic_analysis",
"instruction": "Find and analyze profile pictures across platforms with vision_analyze",
"handle": handle,
})
findings["visual_tasks"].append({
"type": "reverse_image_search",
"instruction": "Reverse image search profile pic via Yandex to find alt accounts",
"handle": handle,
})
return findings
def research_all(handles: list, fidelity: int = 70,
topics: list = None, domain: str = None) -> dict:
"""Research all simulation targets."""
all_findings = {}
for handle in handles:
clean = handle.lstrip("@")
others = [h.lstrip("@") for h in handles if h.lstrip("@") != clean]
findings = research_person(
handle=clean,
fidelity=fidelity,
topics=topics,
other_handles=others,
domain=domain,
)
all_findings[clean] = findings
return all_findings
# ═══════════════════════════════════════════════════════════════
# REPORTING
# ═══════════════════════════════════════════════════════════════
def count_data_points(obj) -> int:
"""Count total search result items in findings (only meaningful items with >50 char text)."""
total = 0
if isinstance(obj, list):
for item in obj:
if isinstance(item, dict):
text = item.get("description") or item.get("text") or ""
if len(text) > 50:
total += 1
else:
# Still count non-dict items or items without text fields
total += 1
else:
total += 1
elif isinstance(obj, dict):
for k, v in obj.items():
# Skip metadata keys
if k in ("handle", "fidelity", "visual_tasks"):
continue
total += count_data_points(v)
return total
def count_quality_data_points(obj) -> int:
"""Count search result items with substantial text (description/text > 50 chars)."""
total = 0
if isinstance(obj, list):
for item in obj:
if isinstance(item, dict):
text = item.get("description") or item.get("text") or ""
if len(text) > 50:
total += 1
elif isinstance(obj, dict):
for k, v in obj.items():
if k in ("handle", "fidelity", "visual_tasks"):
continue
total += count_quality_data_points(v)
return total
def summarize_findings(findings: dict) -> str:
"""Compact summary of what we found."""
handle = findings.get("handle", "unknown")
fidelity = findings.get("fidelity", 0)
total = count_data_points(findings)
quality = count_quality_data_points(findings)
visual_tasks = findings.get("visual_tasks", [])
lines = [
f"\n{''*60}",
f" @{handle} | Fidelity: {fidelity}% | Data points: {total} ({quality} quality)",
f"{''*60}",
]
# Identity snippets
identity = findings.get("identity", {})
for key in ["twitter_identity", "general_identity"]:
for item in identity.get(key, [])[:2]:
if not isinstance(item, dict):
continue
desc = (item.get("description") or "")[:180]
if desc:
lines.append(f" [{key.upper()}] {desc}")
# Platform discovery results
platforms = findings.get("platforms", {})
found_platforms = []
for platform, items in platforms.items():
if isinstance(items, list) and len(items) > 0:
found_platforms.append(platform)
if found_platforms:
lines.append(f" [PLATFORMS FOUND] {', '.join(found_platforms)}")
# Voice samples from aggregators
voice = findings.get("voice", {})
for key, items in voice.items():
if isinstance(items, list):
for item in items[:1]:
if not isinstance(item, dict):
continue
desc = (item.get("description") or "")[:180]
if desc and handle.lower() in desc.lower():
lines.append(f" [VOICE] {desc}")
# Deep extracts
for extract in findings.get("deep_extracts", [])[:2]:
if not isinstance(extract, dict):
continue
title = extract.get("title", "untitled")
content = (extract.get("content") or "")[:200]
if content:
lines.append(f" [LONGFORM: {title}] {content}...")
# Pending visual tasks
if visual_tasks:
lines.append(f" [VISUAL TASKS QUEUED] {len(visual_tasks)} tasks for agent to execute:")
for task in visual_tasks:
lines.append(f"{task.get('type', '?')}: {task.get('instruction', '?')[:80]}")
# Confidence estimate — based on quality data points
if quality >= 30:
conf = "HIGH"
elif quality >= 15:
conf = "MEDIUM"
elif quality >= 5:
conf = "LOW"
else:
conf = "INSUFFICIENT"
lines.append(f"\n CONFIDENCE: {conf} ({quality} quality data points, {total} total)")
return "\n".join(lines)
def report_visual_tasks(all_findings: dict) -> str:
"""Collect all visual tasks across all targets for agent to execute."""
lines = ["\n" + ""*60, " VISUAL INTELLIGENCE TASKS (agent must execute directly)", ""*60]
any_tasks = False
for handle, findings in all_findings.items():
for task in findings.get("visual_tasks", []):
any_tasks = True
lines.append(f"\n @{handle}{task.get('type', '?')}:")
lines.append(f" {task.get('instruction', '?')}")
if not any_tasks:
lines.append(" No visual tasks queued (fidelity < 80)")
return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════
# CHECK AVAILABLE TOOLS
# ═══════════════════════════════════════════════════════════════
def check_x_cli() -> bool:
"""Check if x-cli is available."""
try:
r = terminal("which x-cli 2>/dev/null && echo 'FOUND' || echo 'NOT_FOUND'")
return "FOUND" in r.get("output", "")
except:
return False
# ═══════════════════════════════════════════════════════════════
# ENTRY POINT
# ═══════════════════════════════════════════════════════════════
if __name__ == "__main__":
# ── CONFIGURE THESE ──
HANDLES = ["teknium1", "basedjensen"]
FIDELITY = 80
TOPICS = ["open source AI", "compute scaling"]
DOMAIN = None # Set to 'AI', 'politics', etc. to add domain keywords
# ─────────────────────
has_xcli = check_x_cli()
print(f"x-cli available: {has_xcli}")
print(f"Targets: {HANDLES}")
print(f"Fidelity: {FIDELITY}%")
print(f"Topics: {TOPICS}")
print(f"Domain: {DOMAIN}")
results = research_all(HANDLES, fidelity=FIDELITY, topics=TOPICS, domain=DOMAIN)
for handle, findings in results.items():
print(summarize_findings(findings))
print(report_visual_tasks(results))
print("\n\nResearch phase complete. Agent should now:")
print("1. Execute any queued visual tasks (browser/vision)")
print("2. Compile dossiers from all findings")
print("3. Run simulation")
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
Threads (Meta) Profile & Post Extractor
========================================
Extracts profile data and post content from Threads using:
1. OG meta tags from HTML (no auth required for profiles and public posts)
2. WebFinger for ActivityPub discovery
3. Google-indexed post URLs for recent post discovery
METHODS THAT WORK:
- Profile pages at threads.net/@{user} have OG tags with:
display_name, username, follower_count, thread_count, bio, profile_pic
- Individual post pages have OG tags with:
full post text, author info, profile pic
- WebFinger at /.well-known/webfinger gives ActivityPub user IDs
- Post URLs must be known (discoverable via web search)
METHODS THAT DON'T WORK (as of 2025):
- Threads Official API (graph.threads.net) requires OAuth token
- ActivityPub /ap/users/ endpoints return 404 for most users
- No public post listing endpoint exists
"""
import re
import json
import html
import subprocess
import sys
def curl_fetch(url, extra_headers=None, timeout=15):
"""Fetch URL using curl (more reliable than urllib for Threads)."""
cmd = ['curl', '-s', '-L', '--max-time', str(timeout)]
if extra_headers:
for k, v in extra_headers.items():
cmd.extend(['-H', f'{k}: {v}'])
cmd.append(url)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout+5)
return result.stdout
except:
return None
def extract_og_tags(html_content):
"""Extract OpenGraph, meta description, and Twitter tags from HTML."""
data = {}
if not html_content:
return data
for m in re.finditer(r'property="(og:[^"]+)"\s+content="([^"]*)"', html_content):
key = m.group(1)
val = html.unescape(m.group(2))
if key not in data:
data[key] = val
for m in re.finditer(r'name="description"\s+content="([^"]*)"', html_content):
data['description'] = html.unescape(m.group(1))
break
for m in re.finditer(r'name="(twitter:[^"]+)"\s+content="([^"]*)"', html_content):
key = m.group(1)
val = html.unescape(m.group(2))
if key not in data:
data[key] = val
return data
def parse_profile_description(desc):
"""Parse '5.5M Followers • 142 Threads • Bio. See the latest...' format."""
result = {}
if not desc:
return result
parts = desc.split(' \u2022 ') # Split on bullet •
for part in parts:
part = part.strip()
if 'Follower' in part:
result['followers'] = part.split(' Follower')[0].strip()
elif part.endswith('Threads') or part.endswith('Thread'):
result['thread_count'] = part.split(' Thread')[0].strip()
else:
bio = re.sub(r'\s*See the latest conversations.*$', '', part)
if bio:
result['bio'] = bio
return result
def parse_profile_title(title):
"""Parse 'Display Name (@user) • Threads, Say more' format."""
result = {}
if not title:
return result
m = re.match(r'^(.+?)\s*\(@(\w+)\)', title)
if m:
result['display_name'] = m.group(1).strip()
result['username'] = m.group(2)
return result
def get_threads_profile(username):
"""
Get Threads profile data via OG meta tags.
Returns dict with: username, display_name, bio, followers, thread_count,
profile_picture_url, url
"""
username = username.lstrip('@')
url = f'https://www.threads.net/@{username}'
content = curl_fetch(url)
tags = extract_og_tags(content)
if not tags or 'og:title' not in tags:
return {'error': 'Failed to fetch or parse profile', 'username': username}
title = tags.get('og:title', '')
if title.startswith('Threads') and 'Log in' in title:
return {'error': 'Profile requires login or not found', 'username': username}
result = {
'platform': 'threads',
'url': url,
}
result.update(parse_profile_title(title))
result.update(parse_profile_description(tags.get('og:description', '')))
if 'og:image' in tags:
result['profile_picture_url'] = tags['og:image']
return result
def get_threads_webfinger(username):
"""Get WebFinger data (ActivityPub discovery) for a Threads user."""
username = username.lstrip('@')
url = f'https://www.threads.net/.well-known/webfinger?resource=acct:{username}@threads.net'
content = curl_fetch(url, {'Accept': 'application/json'})
if not content:
return None
try:
data = json.loads(content)
if 'error' in data or 'success' in data and not data['success']:
return None
result = {'subject': data.get('subject', '')}
for link in data.get('links', []):
if link.get('type') == 'application/activity+json':
result['activitypub_url'] = link['href']
elif link.get('rel') == 'http://webfinger.net/rel/profile-page':
result['profile_url'] = link['href']
return result
except:
return None
def get_thread_post(post_url):
"""
Get content of a specific Threads post via OG tags.
Returns: text, author, image_url
"""
content = curl_fetch(post_url)
tags = extract_og_tags(content)
if not tags or 'og:title' not in tags:
return {'error': 'Failed to fetch post'}
title = tags.get('og:title', '')
if 'Log in' in title:
return {'error': 'Post requires login or not found'}
result = {'url': post_url}
if 'og:description' in tags:
result['text'] = tags['og:description']
elif 'description' in tags:
result['text'] = tags['description']
if 'og:title' in tags:
# Parse "Display Name (@username) on Threads"
m = re.match(r'^(.+?)\s*\(@(\w+)\)\s+on\s+Threads', title)
if m:
result['author_name'] = m.group(1).strip()
result['author_username'] = m.group(2)
if 'og:image' in tags:
result['image_url'] = tags['og:image']
return result
def get_threads_full(username):
"""Get complete profile data combining all methods."""
profile = get_threads_profile(username)
wf = get_threads_webfinger(username)
if wf:
profile['webfinger'] = wf
return profile
# ===== TEST =====
if __name__ == '__main__':
test_users = sys.argv[1:] if len(sys.argv) > 1 else ['zuck', 'nvidia', 'mosseri']
for user in test_users:
print(f"\n{'='*60}")
print(f" THREADS PROFILE: @{user}")
print(f"{'='*60}")
data = get_threads_full(user)
for k, v in sorted(data.items()):
if k == 'profile_picture_url':
print(f" {k}: {str(v)[:80]}...")
elif k == 'webfinger':
print(f" webfinger:")
for wk, wv in v.items():
print(f" {wk}: {wv}")
else:
print(f" {k}: {v}")
# Test posts
post_urls = [
'https://www.threads.net/@zuck/post/DEkvXzbyDS9',
]
print(f"\n{'='*60}")
print(f" THREADS POSTS")
print(f"{'='*60}")
for purl in post_urls:
print(f"\n URL: {purl}")
post = get_thread_post(purl)
for k, v in post.items():
if k in ('image_url',):
print(f" {k}: {str(v)[:80]}...")
elif k == 'text':
print(f" {k}: {v[:300]}{'...' if len(v) > 300 else ''}")
else:
print(f" {k}: {v}")
@@ -0,0 +1,305 @@
"""
TikTok Profile & Video Data Scraper
====================================
WORKING methods to get full TikTok profile data and video content.
Tested and verified April 2026.
METHODS SUMMARY:
================
METHOD 1 (BEST): HTML SSR Scraping - Parse __UNIVERSAL_DATA_FOR_REHYDRATION__
- Gets: FULL profile (bio, stats, follower/following/heart/video counts)
- Works: YES - Reliable, no auth needed, just curl + parse
- Limitation: No video list on profile page (videos load client-side)
METHOD 2: oEmbed API - https://www.tiktok.com/oembed?url=...
- Gets: Video title/caption, author, thumbnail URL
- Works: YES - No auth, no rate limit issues
- Limitation: Need video IDs first; no engagement stats
METHOD 3: tikwm.com API - https://www.tikwm.com/api/
- Gets: Full user info + individual video stats (plays, likes, comments, shares)
- User info: https://www.tikwm.com/api/user/info?unique_id={username}
- Video info: https://www.tikwm.com/api/?url={tiktok_video_url}
- Works: YES for user info and single videos
- Limitation: Posts list endpoint returns 403 (rate-limited)
METHOD 4: Video ID Discovery via Search Engines
- Use web_search("site:tiktok.com/@{username}/video") to find video IDs
- Then use oEmbed or tikwm or HTML scraping per video
- Works: YES - Gets ~5 recent video IDs per search
METHOD 5: SocialBlade via web_extract
- URL: https://socialblade.com/tiktok/user/{username}
- Gets: Followers, following, likes, videos, growth trends, rankings
- Works: YES via web_extract tool
METHOD 6: Individual Video HTML Scraping
- Fetch https://www.tiktok.com/@{user}/video/{id}
- Parse __UNIVERSAL_DATA webapp.video-detail -> itemInfo.itemStruct
- Gets: FULL video data (caption, stats, music, hashtags, duration)
- Works: YES - Most complete per-video data
NOT WORKING:
- TikTok /api/user/detail/ endpoint -> returns empty (needs signed params)
- TikTok /api/post/item_list/ -> returns empty (needs x-bogus/msToken)
- tikwm.com /api/user/posts -> 403 forbidden
"""
import re
import json
import subprocess
import urllib.parse
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
def fetch_url(url, headers=None):
"""Fetch URL via curl and return content."""
cmd = ['curl', '-s', '-L', '-m', '30', url,
'-H', f'User-Agent: {USER_AGENT}',
'-H', 'Accept-Language: en-US,en;q=0.9']
if headers:
for k, v in headers.items():
cmd.extend(['-H', f'{k}: {v}'])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=35)
return result.stdout
def method1_html_profile(username):
"""
METHOD 1: Scrape TikTok profile HTML and parse SSR JSON data.
Returns full profile with stats.
"""
url = f'https://www.tiktok.com/@{username}'
html = fetch_url(url)
m = re.search(
r'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">(.*?)</script>',
html
)
if not m:
return None
data = json.loads(m.group(1))
scope = data.get('__DEFAULT_SCOPE__', {})
user_detail = scope.get('webapp.user-detail', {})
user_info = user_detail.get('userInfo', {})
if not user_info:
return None
user = user_info.get('user', {})
stats = user_info.get('statsV2', user_info.get('stats', {}))
return {
'id': user.get('id'),
'username': user.get('uniqueId'),
'nickname': user.get('nickname'),
'bio': user.get('signature'),
'verified': user.get('verified'),
'private': user.get('privateAccount'),
'secUid': user.get('secUid'),
'avatarLarger': user.get('avatarLarger'),
'bioLink': user.get('bioLink', {}),
'createTime': user.get('createTime'),
'language': user.get('language'),
'stats': {
'followers': int(stats.get('followerCount', 0)),
'following': int(stats.get('followingCount', 0)),
'hearts': int(stats.get('heartCount', 0)),
'videos': int(stats.get('videoCount', 0)),
'diggs': int(stats.get('diggCount', 0)),
'friends': int(stats.get('friendCount', 0)),
}
}
def method2_oembed_video(username, video_id):
"""
METHOD 2: Get video caption/title via oEmbed.
No auth needed. Returns caption, author, thumbnail.
"""
url = f'https://www.tiktok.com/oembed?url=https://www.tiktok.com/@{username}/video/{video_id}'
content = fetch_url(url)
try:
data = json.loads(content)
return {
'video_id': video_id,
'title': data.get('title', ''),
'author_name': data.get('author_name'),
'author_url': data.get('author_url'),
'thumbnail_url': data.get('thumbnail_url'),
'thumbnail_width': data.get('thumbnail_width'),
'thumbnail_height': data.get('thumbnail_height'),
}
except json.JSONDecodeError:
return None
def method3_tikwm_user(username):
"""
METHOD 3a: Get user info via tikwm.com API.
"""
url = f'https://www.tikwm.com/api/user/info?unique_id={username}'
content = fetch_url(url)
try:
data = json.loads(content)
if data.get('code') == 0:
return data['data']
except json.JSONDecodeError:
pass
return None
def method3_tikwm_video(video_url):
"""
METHOD 3b: Get video details via tikwm.com API.
Returns: title, play_count, digg_count, comment_count, share_count, duration, download URLs
"""
url = f'https://www.tikwm.com/api/?url={urllib.parse.quote(video_url)}'
content = fetch_url(url)
try:
data = json.loads(content)
if data.get('code') == 0:
v = data['data']
return {
'video_id': v.get('id'),
'title': v.get('title'),
'duration': v.get('duration'),
'play_count': v.get('play_count'),
'likes': v.get('digg_count'),
'comments': v.get('comment_count'),
'shares': v.get('share_count'),
'author': v.get('author', {}).get('unique_id'),
'music_title': v.get('music_info', {}).get('title') if v.get('music_info') else None,
'cover_url': v.get('origin_cover') or v.get('cover'),
'play_url': v.get('play'), # direct video URL
}
except json.JSONDecodeError:
pass
return None
def method6_html_video(username, video_id):
"""
METHOD 6: Scrape individual video page HTML for full data.
Gets: caption, full stats, music, hashtags, create time.
"""
url = f'https://www.tiktok.com/@{username}/video/{video_id}'
html = fetch_url(url)
m = re.search(
r'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">(.*?)</script>',
html
)
if not m:
return None
data = json.loads(m.group(1))
scope = data.get('__DEFAULT_SCOPE__', {})
vd = scope.get('webapp.video-detail', {})
item = vd.get('itemInfo', {}).get('itemStruct', {})
if not item:
return None
stats = item.get('statsV2', item.get('stats', {}))
music = item.get('music', {})
challenges = item.get('challenges', [])
return {
'video_id': item.get('id'),
'description': item.get('desc'),
'createTime': item.get('createTime'),
'duration': item.get('video', {}).get('duration'),
'stats': {
'plays': int(stats.get('playCount', 0)),
'likes': int(stats.get('diggCount', 0)),
'comments': int(stats.get('commentCount', 0)),
'shares': int(stats.get('shareCount', 0)),
'saves': int(stats.get('collectCount', 0)),
},
'music': {
'title': music.get('title'),
'author': music.get('authorName'),
},
'hashtags': [c.get('title', '') for c in challenges],
'author': item.get('author', {}).get('uniqueId'),
}
def get_full_tiktok_profile(username):
"""
Complete pipeline: Get full profile + discover and scrape recent videos.
Returns dict with profile data, stats, and recent video details.
"""
# Step 1: Get profile data
profile = method1_html_profile(username)
if not profile:
return {'error': f'Could not fetch profile for @{username}'}
result = {
'profile': profile,
'videos': [],
'data_sources': ['tiktok_html_ssr'],
}
# Note: Video discovery requires web_search tool (not available in pure Python)
# In the agent context, use:
# web_search(f"site:tiktok.com/@{username}/video")
# Then for each video ID found, call method6_html_video() or method2_oembed_video()
return result
if __name__ == '__main__':
import sys
username = sys.argv[1] if len(sys.argv) > 1 else 'khaby.lame'
print(f'=== Testing TikTok scraping for @{username} ===\n')
print('--- METHOD 1: HTML Profile Scraping ---')
profile = method1_html_profile(username)
if profile:
print(f' Username: {profile["username"]}')
print(f' Nickname: {profile["nickname"]}')
print(f' Bio: {profile["bio"][:100]}')
print(f' Verified: {profile["verified"]}')
print(f' Followers: {profile["stats"]["followers"]:,}')
print(f' Following: {profile["stats"]["following"]:,}')
print(f' Hearts: {profile["stats"]["hearts"]:,}')
print(f' Videos: {profile["stats"]["videos"]:,}')
print(f' SecUid: {profile["secUid"][:50]}...')
else:
print(' FAILED')
print('\n--- METHOD 3a: tikwm.com User API ---')
tikwm_user = method3_tikwm_user(username)
if tikwm_user:
s = tikwm_user.get('stats', {})
print(f' Followers: {s.get("followerCount"):,}')
print(f' Hearts: {s.get("heartCount"):,}')
print(f' Videos: {s.get("videoCount"):,}')
else:
print(' FAILED')
# Test with a known video
test_video_id = '7615318641042623775' # khaby birthday video
if username == 'khaby.lame':
print(f'\n--- METHOD 2: oEmbed for video {test_video_id} ---')
oembed = method2_oembed_video(username, test_video_id)
if oembed:
print(f' Title: {oembed["title"][:80]}')
print(f'\n--- METHOD 6: HTML Video Scraping for {test_video_id} ---')
video = method6_html_video(username, test_video_id)
if video:
print(f' Description: {video["description"][:80]}')
print(f' Plays: {video["stats"]["plays"]:,}')
print(f' Likes: {video["stats"]["likes"]:,}')
print(f' Comments: {video["stats"]["comments"]:,}')
print(f' Shares: {video["stats"]["shares"]:,}')
print(f' Hashtags: {video["hashtags"]}')
print('\n=== DONE ===')
+260
View File
@@ -0,0 +1,260 @@
"""
Direct X/Twitter API v2 client for Hermes Simulator.
No x-cli dependency uses curl via terminal() with bearer token.
Provides:
- get_user(handle) profile, bio, metrics
- get_tweets(user_id, count) recent tweets with metrics
- search_tweets(query, count) search for tweets
- get_user_mentions(user_id, count) mentions of a user
"""
from hermes_tools import terminal
import json
import os
import time
import urllib.parse
# Bearer token — loaded from env or hardcoded fallback
BEARER = os.environ.get("X_BEARER_TOKEN", "")
MAX_RETRIES = 3
BASE_DELAY = 2 # seconds, exponential backoff: 2s, 4s, 8s
def _api_get(endpoint: str, params: dict = None) -> dict:
"""Make authenticated GET request to X API v2 with retry and error handling."""
url = f"https://api.twitter.com/2/{endpoint}"
if params:
qs = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items())
url += f"?{qs}"
for attempt in range(MAX_RETRIES):
try:
r = terminal(f'curl -s -w \'\\n%{{http_code}}\' -H "Authorization: Bearer {BEARER}" "{url}"')
output = r.get("output", "").strip()
# Split body from status code (last line)
lines = output.rsplit("\n", 1)
if len(lines) == 2:
body, status_str = lines
else:
body = output
status_str = "0"
try:
status_code = int(status_str.strip())
except ValueError:
status_code = 0
# Handle specific status codes
if status_code == 429:
# Rate limited — retry with backoff
delay = BASE_DELAY * (2 ** attempt)
print(f" [X API] Rate limited (429). Retry {attempt+1}/{MAX_RETRIES} in {delay}s...")
time.sleep(delay)
continue
if status_code in (401, 403):
return {"error": f"Authentication failed (HTTP {status_code}). Check X_BEARER_TOKEN.", "http_status": status_code}
if status_code >= 500:
delay = BASE_DELAY * (2 ** attempt)
print(f" [X API] Server error ({status_code}). Retry {attempt+1}/{MAX_RETRIES} in {delay}s...")
time.sleep(delay)
continue
if status_code == 0 and not body:
# Network error — no response at all
delay = BASE_DELAY * (2 ** attempt)
print(f" [X API] Network error. Retry {attempt+1}/{MAX_RETRIES} in {delay}s...")
time.sleep(delay)
continue
try:
return json.loads(body)
except json.JSONDecodeError:
return {"error": f"Failed to parse response (HTTP {status_code}): {body[:200]}"}
except Exception as e:
delay = BASE_DELAY * (2 ** attempt)
print(f" [X API] Exception: {e}. Retry {attempt+1}/{MAX_RETRIES} in {delay}s...")
time.sleep(delay)
continue
return {"error": f"All {MAX_RETRIES} retries exhausted for {endpoint}"}
def get_user(handle: str) -> dict:
"""Get user profile by handle."""
handle = handle.lstrip("@")
return _api_get(f"users/by/username/{handle}", {
"user.fields": "description,public_metrics,profile_image_url,created_at,location,url"
})
def get_tweets(user_id: str, count: int = 20) -> dict:
"""Get user's recent tweets."""
return _api_get(f"users/{user_id}/tweets", {
"max_results": max(min(count, 100), 5),
"tweet.fields": "created_at,public_metrics,text,in_reply_to_user_id,referenced_tweets",
"exclude": "retweets" # original tweets only for voice analysis
})
def get_tweets_with_rts(user_id: str, count: int = 20) -> dict:
"""Get user's recent tweets including retweets (shows interests)."""
return _api_get(f"users/{user_id}/tweets", {
"max_results": max(min(count, 100), 5),
"tweet.fields": "created_at,public_metrics,text,referenced_tweets"
})
def search_tweets(query: str, count: int = 10) -> dict:
"""Search recent tweets."""
return _api_get("tweets/search/recent", {
"query": query,
"max_results": max(min(count, 100), 10),
"tweet.fields": "created_at,public_metrics,text,author_id"
})
def get_user_by_id(user_id: str) -> dict:
"""Get user profile by ID."""
return _api_get(f"users/{user_id}", {
"user.fields": "description,public_metrics,username,name"
})
# ═══════════════════════════════════════════════════════════════
# HIGH-LEVEL INTELLIGENCE FUNCTIONS
# ═══════════════════════════════════════════════════════════════
def profile_user(handle: str) -> dict:
"""Full profile pull: identity + recent tweets (originals only)."""
user = get_user(handle)
if "errors" in user or "error" in user:
return {"error": f"User @{handle} not found", "details": user}
user_data = user.get("data", {})
user_id = user_data.get("id")
result = {
"profile": user_data,
"tweets": [],
"voice_samples": [],
}
if user_id:
# Get original tweets (no RTs) for voice analysis
tweets = get_tweets(user_id, 20)
tweet_list = tweets.get("data", [])
result["tweets"] = tweet_list
# Extract pure text samples for voice profiling
# Only exclude retweets and actual replies (has in_reply_to_user_id)
# Tweets starting with @ are fine if they're standalone mentions
result["voice_samples"] = [
t["text"] for t in tweet_list
if not t.get("text", "").startswith("RT @")
and not t.get("in_reply_to_user_id")
]
return result
def profile_interactions(handle1: str, handle2: str) -> dict:
"""Find interactions between two users."""
# Search for replies from handle1 to handle2
q1 = f"from:{handle1} to:{handle2}"
q2 = f"from:{handle2} to:{handle1}"
r1 = search_tweets(q1, 10)
r2 = search_tweets(q2, 10)
return {
f"{handle1}_to_{handle2}": r1.get("data", []),
f"{handle2}_to_{handle1}": r2.get("data", []),
}
def get_voice_data(handle: str, count: int = 50) -> dict:
"""Pull maximum voice data: tweets, replies, quote tweets.
Returns categorized samples for voice profiling."""
user = get_user(handle)
if "errors" in user or "error" in user:
return {"error": f"User @{handle} not found"}
user_data = user.get("data", {})
user_id = user_data.get("id")
if not user_id:
return {"error": "No user ID found"}
# Original tweets (exclude RTs)
originals = get_tweets(user_id, min(count, 100))
original_list = originals.get("data", [])
# Categorize — only use in_reply_to_user_id to detect replies
standalone = [] # not replies
replies = [] # replies to others
for t in original_list:
text = t.get("text", "")
if t.get("in_reply_to_user_id"):
replies.append(text)
else:
standalone.append(text)
return {
"profile": user_data,
"standalone_tweets": standalone, # their voice at rest
"replies": replies, # their voice in conversation
"total_samples": len(standalone) + len(replies),
}
# ═══════════════════════════════════════════════════════════════
# ENTRY POINT
# ═══════════════════════════════════════════════════════════════
if __name__ == "__main__":
if not BEARER:
print("ERROR: X_BEARER_TOKEN not set. Set it in environment or ~/.hermes/.env")
print("Trying to load from .env...")
try:
with open(os.path.expanduser("~/.hermes/.env")) as f:
for line in f:
line = line.strip()
if line.startswith("X_BEARER_TOKEN="):
# Use split with maxsplit=1 to handle values with '=' in them
# Also strip surrounding quotes if present
val = line.split("=", 1)[1]
if val and val[0] in ('"', "'") and val[-1] == val[0]:
val = val[1:-1]
BEARER = val
break
except Exception as e:
print(f" Failed to load .env: {e}")
if not BEARER:
print("FATAL: No bearer token found.")
exit(1)
# Demo: profile two users
for handle in ["Teknium", "basedjensen"]:
print(f"\n{'='*60}")
print(f" PROFILING @{handle}")
print(f"{'='*60}")
data = profile_user(handle)
profile = data.get("profile", {})
print(f" Name: {profile.get('name')}")
print(f" Bio: {profile.get('description')}")
metrics = profile.get("public_metrics", {})
print(f" Followers: {metrics.get('followers_count')}")
print(f" Tweets: {metrics.get('tweet_count')}")
print(f" Likes given: {metrics.get('like_count')}")
print(f"\n Voice samples ({len(data.get('voice_samples', []))}):")
for sample in data.get("voice_samples", [])[:5]:
print(f" > {sample[:120]}")
@@ -0,0 +1,136 @@
# DOSSIER: {display_name} (@{handle})
## Identity
- **Name**: {real_name}
- **Handle(s)**: @{twitter} | u/{reddit} | {discord_tag}
- **Role**: {role_and_org}
- **Known for**: {what_they_are_famous_for}
- **Followers/reach**: {approximate_follower_count}
- **Confidence**: {HIGH|MEDIUM|LOW} — {confidence_reason}
## Voice Profile
### Linguistic Patterns
- **Sentence structure**: {short_punchy | long_flowing | mixed}
- **Capitalization**: {normal | all_lowercase | CAPS_FOR_EMPHASIS | mixed}
- **Punctuation**: {heavy_periods | ellipsis_lover | no_punctuation | exclamation_marks}
- **Paragraph style**: {one_liners | thread_essays | medium_blocks}
- **Emoji/emoticon usage**: {none | minimal | heavy | specific_ones}
### Vocabulary & Slang
- **Register**: {academic | casual | shitposter | mixed}
- **Recurring words/phrases**: [list of signature words they use a lot]
- **Catchphrases**: [any repeated phrases or running jokes]
- **Profanity level**: {none | mild | moderate | heavy}
- **Jargon tendency**: {explains_everything | assumes_expertise | mixes}
### Tone
- **Default mood**: {earnest | ironic | combative | chill | manic | analytical}
- **Humor style**: {deadpan | absurdist | sarcastic | wholesome | shitpost | none}
- **How they handle disagreement**: {engages_thoughtfully | dunks | ignores | ratio_warrior | passive_aggressive}
- **How they handle praise**: {deflects | accepts_gracefully | awkward | flexes}
## Positions & Beliefs
### Core Convictions (things they consistently advocate for)
1. {conviction_1}
2. {conviction_2}
3. {conviction_3}
### Known Hot Takes
1. {take_1}
2. {take_2}
### Hills They'll Die On
1. {hill_1}
2. {hill_2}
### Topics They Avoid or Refuse to Engage
1. {avoidance_1}
## Social Dynamics
### People They Interact With Positively
- @{ally_1} — {relationship_description}
- @{ally_2} — {relationship_description}
### People They Beef With / Disagree With
- @{rival_1} — {beef_description}
### How They Engage Different Types
- **Fans/supporters**: {how_they_respond}
- **Critics**: {how_they_respond}
- **Peers**: {how_they_respond}
- **Random people**: {how_they_respond}
## Platform-Specific Behavior
### On Twitter/X
- **Post frequency**: {multiple_daily | daily | few_per_week}
- **Thread tendency**: {never | sometimes | loves_threads}
- **QRT style**: {adds_context | dunks | amplifies}
- **Engagement style**: {likes_a_lot | rarely_likes | retweets_heavy}
### On Reddit (if applicable)
- **Subreddits**: [list]
- **Comment style**: {detailed | brief | combative}
### On Discord (if applicable)
- **Servers**: [known servers]
- **Vibe shift from Twitter**: {description}
## Signature Moves
Things this person characteristically does that make them recognizable:
1. {signature_move_1}
2. {signature_move_2}
3. {signature_move_3}
## Sample Quotes (real, sourced from research)
> "{actual_quote_1}" — [source/context]
> "{actual_quote_2}" — [source/context]
> "{actual_quote_3}" — [source/context]
## Deep Psychometric Profile
- **Big Five**: O{H/M/L} C{} E{} A{} N{} — {evidence}
- **Moral Foundations**: Care{} Fair{} Loyal{} Auth{} Sanct{} Liberty{} — {what drives their ethics}
- **Schwartz Values**: {dominant values} — {how they justify positions}
- **Cognitive Style**: {IC score estimate} — {hedging patterns, complexity, analytical vs intuitive}
- **Narrative Frame**: {dominant frame} — {how they lens issues}
- **Persona Authenticity**: {1-5 score} — {evidence for curation vs authenticity}
## Strategic Self-Presentation (Red Hat)
- **Cultivated image**: {what they want to be seen as}
- **Target audience**: {who they're performing for}
- **Incentive structure**: {what they gain from this persona}
- **Possible divergences**: {where persona may ≠ person}
- **Ghostwriting indicators**: {present/absent, evidence}
## Ecosystem Context
- **Community cluster**: {which tribe they belong to}
- **Key influencers**: {who they amplify/follow/agree with}
- **Echo chamber**: {what information environment they're in}
- **Audience profile**: {who follows them, how that audience reacts}
## Key Assumptions
1. {assumption} — FRAGILITY: {robust/moderate/fragile} — Test: {what invalidates it}
2. {assumption} — FRAGILITY: {} — Test: {}
3. {assumption} — FRAGILITY: {} — Test: {}
## Competing Hypotheses
- **H1 (PRIMARY)**: {main personality model} — Confidence: {X}%
- **H2 (ALTERNATIVE)**: {alternative explanation} — Confidence: {X}%
- **Key discriminator**: {what evidence would shift between H1 and H2}
## Research Sources
- {source_1} [{reliability}{confidence}] — {description}
- {source_2} [{reliability}{confidence}] — {description}
- {source_3} [{reliability}{confidence}] — {description}
## Invalidation Indicators
1. If @{handle} {does X instead of Y}, our {assessment} is wrong
2. If @{handle} {responds to Z with Q}, our {model} needs revision
3. If @{handle} {interacts with @person in manner M}, dynamics model is off
---
*Dossier compiled: {date} | Fidelity: {fidelity}% | Persona Authenticity: {1-5}*
*Source reliability range: {best}-{worst} | Analytical confidence: {1-6}*
+7 -7
View File
@@ -23,11 +23,11 @@ import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -321,7 +321,7 @@ class ByteRoverMemoryProvider(MemoryProvider):
return self._tool_curate(args)
elif tool_name == "brv_status":
return self._tool_status()
return json.dumps({"error": f"Unknown tool: {tool_name}"})
return tool_error(f"Unknown tool: {tool_name}")
def shutdown(self) -> None:
if self._sync_thread and self._sync_thread.is_alive():
@@ -332,7 +332,7 @@ class ByteRoverMemoryProvider(MemoryProvider):
def _tool_query(self, args: dict) -> str:
query = args.get("query", "")
if not query:
return json.dumps({"error": "query is required"})
return tool_error("query is required")
result = _run_brv(
["query", "--", query.strip()[:5000]],
@@ -340,7 +340,7 @@ class ByteRoverMemoryProvider(MemoryProvider):
)
if not result["success"]:
return json.dumps({"error": result.get("error", "Query failed")})
return tool_error(result.get("error", "Query failed"))
output = result.get("output", "").strip()
if not output or len(output) < _MIN_OUTPUT_LEN:
@@ -355,7 +355,7 @@ class ByteRoverMemoryProvider(MemoryProvider):
def _tool_curate(self, args: dict) -> str:
content = args.get("content", "")
if not content:
return json.dumps({"error": "content is required"})
return tool_error("content is required")
result = _run_brv(
["curate", "--", content],
@@ -363,14 +363,14 @@ class ByteRoverMemoryProvider(MemoryProvider):
)
if not result["success"]:
return json.dumps({"error": result.get("error", "Curate failed")})
return tool_error(result.get("error", "Curate failed"))
return json.dumps({"result": "Memory curated successfully."})
def _tool_status(self) -> str:
result = _run_brv(["status"], timeout=15, cwd=self._cwd)
if not result["success"]:
return json.dumps({"error": result.get("error", "Status check failed")})
return tool_error(result.get("error", "Status check failed"))
return json.dumps({"status": result.get("output", "")})
+10 -10
View File
@@ -26,6 +26,7 @@ import threading
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -290,8 +291,7 @@ class HindsightMemoryProvider(MemoryProvider):
if self._mode == "local":
def _start_daemon():
import traceback
from pathlib import Path
log_dir = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))) / "logs"
log_dir = get_hermes_home() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / "hindsight-embed.log"
try:
@@ -434,12 +434,12 @@ class HindsightMemoryProvider(MemoryProvider):
client = self._get_client()
except Exception as e:
logger.warning("Hindsight client init failed: %s", e)
return json.dumps({"error": f"Hindsight client unavailable: {e}"})
return tool_error(f"Hindsight client unavailable: {e}")
if tool_name == "hindsight_retain":
content = args.get("content", "")
if not content:
return json.dumps({"error": "Missing required parameter: content"})
return tool_error("Missing required parameter: content")
context = args.get("context")
try:
_run_sync(client.aretain(
@@ -448,12 +448,12 @@ class HindsightMemoryProvider(MemoryProvider):
return json.dumps({"result": "Memory stored successfully."})
except Exception as e:
logger.warning("hindsight_retain failed: %s", e)
return json.dumps({"error": f"Failed to store memory: {e}"})
return tool_error(f"Failed to store memory: {e}")
elif tool_name == "hindsight_recall":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
return tool_error("Missing required parameter: query")
try:
resp = _run_sync(client.arecall(
bank_id=self._bank_id, query=query, budget=self._budget
@@ -464,12 +464,12 @@ class HindsightMemoryProvider(MemoryProvider):
return json.dumps({"result": "\n".join(lines)})
except Exception as e:
logger.warning("hindsight_recall failed: %s", e)
return json.dumps({"error": f"Failed to search memory: {e}"})
return tool_error(f"Failed to search memory: {e}")
elif tool_name == "hindsight_reflect":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
return tool_error("Missing required parameter: query")
try:
resp = _run_sync(client.areflect(
bank_id=self._bank_id, query=query, budget=self._budget
@@ -477,9 +477,9 @@ class HindsightMemoryProvider(MemoryProvider):
return json.dumps({"result": resp.text or "No relevant memories found."})
except Exception as e:
logger.warning("hindsight_reflect failed: %s", e)
return json.dumps({"error": f"Failed to reflect: {e}"})
return tool_error(f"Failed to reflect: {e}")
return json.dumps({"error": f"Unknown tool: {tool_name}"})
return tool_error(f"Unknown tool: {tool_name}")
def shutdown(self) -> None:
global _loop, _loop_thread
+8 -8
View File
@@ -20,10 +20,10 @@ from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
from .store import MemoryStore
from .retrieval import FactRetriever
@@ -231,7 +231,7 @@ class HolographicMemoryProvider(MemoryProvider):
return self._handle_fact_store(args)
elif tool_name == "fact_feedback":
return self._handle_fact_feedback(args)
return json.dumps({"error": f"Unknown tool: {tool_name}"})
return tool_error(f"Unknown tool: {tool_name}")
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
if not self._config.get("auto_extract", False):
@@ -297,7 +297,7 @@ class HolographicMemoryProvider(MemoryProvider):
elif action == "reason":
entities = args.get("entities", [])
if not entities:
return json.dumps({"error": "reason requires 'entities' list"})
return tool_error("reason requires 'entities' list")
results = retriever.reason(
entities,
category=args.get("category"),
@@ -335,12 +335,12 @@ class HolographicMemoryProvider(MemoryProvider):
return json.dumps({"facts": facts, "count": len(facts)})
else:
return json.dumps({"error": f"Unknown action: {action}"})
return tool_error(f"Unknown action: {action}")
except KeyError as exc:
return json.dumps({"error": f"Missing required argument: {exc}"})
return tool_error(f"Missing required argument: {exc}")
except Exception as exc:
return json.dumps({"error": str(exc)})
return tool_error(str(exc))
def _handle_fact_feedback(self, args: dict) -> str:
try:
@@ -349,9 +349,9 @@ class HolographicMemoryProvider(MemoryProvider):
result = self._store.record_feedback(fact_id, helpful=helpful)
return json.dumps(result)
except KeyError as exc:
return json.dumps({"error": f"Missing required argument: {exc}"})
return tool_error(f"Missing required argument: {exc}")
except Exception as exc:
return json.dumps({"error": str(exc)})
return tool_error(str(exc))
# -- Auto-extraction (on_session_end) ------------------------------------
-1
View File
@@ -6,7 +6,6 @@ Single-user Hermes memory store plugin.
import re
import sqlite3
import threading
from datetime import datetime
from pathlib import Path
try:
+16 -10
View File
@@ -18,10 +18,10 @@ from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -217,6 +217,12 @@ class HonchoMemoryProvider(MemoryProvider):
logger.debug("Honcho not configured — plugin inactive")
return
# Override peer_name with gateway user_id for per-user memory scoping.
# CLI sessions won't have user_id, so the config default is preserved.
_gw_user_id = kwargs.get("user_id")
if _gw_user_id:
cfg.peer_name = _gw_user_id
self._config = cfg
# ----- B1: recall_mode from config -----
@@ -633,15 +639,15 @@ class HonchoMemoryProvider(MemoryProvider):
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
"""Handle a Honcho tool call, with lazy session init for tools-only mode."""
if self._cron_skipped:
return json.dumps({"error": "Honcho is not active (cron context)."})
return tool_error("Honcho is not active (cron context).")
# Port #1957: ensure session is initialized for tools-only mode
if not self._session_initialized:
if not self._ensure_session():
return json.dumps({"error": "Honcho session could not be initialized."})
return tool_error("Honcho session could not be initialized.")
if not self._manager or not self._session_key:
return json.dumps({"error": "Honcho is not active for this session."})
return tool_error("Honcho is not active for this session.")
try:
if tool_name == "honcho_profile":
@@ -653,7 +659,7 @@ class HonchoMemoryProvider(MemoryProvider):
elif tool_name == "honcho_search":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
return tool_error("Missing required parameter: query")
max_tokens = min(int(args.get("max_tokens", 800)), 2000)
result = self._manager.search_context(
self._session_key, query, max_tokens=max_tokens
@@ -665,7 +671,7 @@ class HonchoMemoryProvider(MemoryProvider):
elif tool_name == "honcho_context":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
return tool_error("Missing required parameter: query")
peer = args.get("peer", "user")
result = self._manager.dialectic_query(
self._session_key, query, peer=peer
@@ -675,17 +681,17 @@ class HonchoMemoryProvider(MemoryProvider):
elif tool_name == "honcho_conclude":
conclusion = args.get("conclusion", "")
if not conclusion:
return json.dumps({"error": "Missing required parameter: conclusion"})
return tool_error("Missing required parameter: conclusion")
ok = self._manager.create_conclusion(self._session_key, conclusion)
if ok:
return json.dumps({"result": f"Conclusion saved: {conclusion}"})
return json.dumps({"error": "Failed to save conclusion."})
return tool_error("Failed to save conclusion.")
return json.dumps({"error": f"Unknown tool: {tool_name}"})
return tool_error(f"Unknown tool: {tool_name}")
except Exception as e:
logger.error("Honcho tool %s failed: %s", tool_name, e)
return json.dumps({"error": f"Honcho {tool_name} failed: {e}"})
return tool_error(f"Honcho {tool_name} failed: {e}")
def shutdown(self) -> None:
for t in (self._prefetch_thread, self._sync_thread):
+1 -2
View File
@@ -11,7 +11,7 @@ import sys
from pathlib import Path
from hermes_constants import get_hermes_home
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, GLOBAL_CONFIG_PATH, HOST
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, HOST
def clone_honcho_for_profile(profile_name: str) -> bool:
@@ -1220,7 +1220,6 @@ def register_cli(subparser) -> None:
Called by the plugin CLI registration system during argparse setup.
The *subparser* is the parser for ``hermes honcho``.
"""
import argparse
subparser.add_argument(
"--target-profile", metavar="NAME", dest="target_profile",
+11 -9
View File
@@ -20,10 +20,10 @@ import logging
import os
import threading
import time
from pathlib import Path
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -203,7 +203,9 @@ class Mem0MemoryProvider(MemoryProvider):
def initialize(self, session_id: str, **kwargs) -> None:
self._config = _load_config()
self._api_key = self._config.get("api_key", "")
self._user_id = self._config.get("user_id", "hermes-user")
# Prefer gateway-provided user_id for per-user memory scoping;
# fall back to config/env default for CLI (single-user) sessions.
self._user_id = kwargs.get("user_id") or self._config.get("user_id", "hermes-user")
self._agent_id = self._config.get("agent_id", "hermes")
self._rerank = self._config.get("rerank", True)
@@ -304,7 +306,7 @@ class Mem0MemoryProvider(MemoryProvider):
try:
client = self._get_client()
except Exception as e:
return json.dumps({"error": str(e)})
return tool_error(str(e))
if tool_name == "mem0_profile":
try:
@@ -316,12 +318,12 @@ class Mem0MemoryProvider(MemoryProvider):
return json.dumps({"result": "\n".join(lines), "count": len(lines)})
except Exception as e:
self._record_failure()
return json.dumps({"error": f"Failed to fetch profile: {e}"})
return tool_error(f"Failed to fetch profile: {e}")
elif tool_name == "mem0_search":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
return tool_error("Missing required parameter: query")
rerank = args.get("rerank", False)
top_k = min(int(args.get("top_k", 10)), 50)
try:
@@ -338,12 +340,12 @@ class Mem0MemoryProvider(MemoryProvider):
return json.dumps({"results": items, "count": len(items)})
except Exception as e:
self._record_failure()
return json.dumps({"error": f"Search failed: {e}"})
return tool_error(f"Search failed: {e}")
elif tool_name == "mem0_conclude":
conclusion = args.get("conclusion", "")
if not conclusion:
return json.dumps({"error": "Missing required parameter: conclusion"})
return tool_error("Missing required parameter: conclusion")
try:
client.add(
[{"role": "user", "content": conclusion}],
@@ -354,9 +356,9 @@ class Mem0MemoryProvider(MemoryProvider):
return json.dumps({"result": "Fact stored."})
except Exception as e:
self._record_failure()
return json.dumps({"error": f"Failed to store: {e}"})
return tool_error(f"Failed to store: {e}")
return json.dumps({"error": f"Unknown tool: {tool_name}"})
return tool_error(f"Unknown tool: {tool_name}")
def shutdown(self) -> None:
for t in (self._prefetch_thread, self._sync_thread):
+8 -7
View File
@@ -31,6 +31,7 @@ import threading
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -461,7 +462,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
if not self._client:
return json.dumps({"error": "OpenViking server not connected"})
return tool_error("OpenViking server not connected")
try:
if tool_name == "viking_search":
@@ -474,9 +475,9 @@ class OpenVikingMemoryProvider(MemoryProvider):
return self._tool_remember(args)
elif tool_name == "viking_add_resource":
return self._tool_add_resource(args)
return json.dumps({"error": f"Unknown tool: {tool_name}"})
return tool_error(f"Unknown tool: {tool_name}")
except Exception as e:
return json.dumps({"error": str(e)})
return tool_error(str(e))
def shutdown(self) -> None:
# Wait for background threads to finish
@@ -493,7 +494,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
def _tool_search(self, args: dict) -> str:
query = args.get("query", "")
if not query:
return json.dumps({"error": "query is required"})
return tool_error("query is required")
payload: Dict[str, Any] = {"query": query}
mode = args.get("mode", "auto")
@@ -530,7 +531,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
def _tool_read(self, args: dict) -> str:
uri = args.get("uri", "")
if not uri:
return json.dumps({"error": "uri is required"})
return tool_error("uri is required")
level = args.get("level", "overview")
# Map our level names to OpenViking GET endpoints
@@ -582,7 +583,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
def _tool_remember(self, args: dict) -> str:
content = args.get("content", "")
if not content:
return json.dumps({"error": "content is required"})
return tool_error("content is required")
# Store as a session message that will be extracted during commit.
# The category hint helps OpenViking's extraction classify correctly.
@@ -606,7 +607,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
def _tool_add_resource(self, args: dict) -> str:
url = args.get("url", "")
if not url:
return json.dumps({"error": "url is required"})
return tool_error("url is required")
payload: Dict[str, Any] = {"path": url}
if args.get("reason"):
+6 -5
View File
@@ -20,7 +20,6 @@ Config (env vars or hermes config.yaml under retaindb:):
from __future__ import annotations
import hashlib
import json
import logging
import os
@@ -35,6 +34,7 @@ from typing import Any, Dict, List
from urllib.parse import quote
from agent.memory_provider import MemoryProvider
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -189,7 +189,7 @@ class _Client:
"Content-Type": "application/json",
"x-sdk-runtime": "hermes-plugin",
}
if path.startswith("/v1/memory") or path.startswith("/v1/context"):
if path.startswith(("/v1/memory", "/v1/context")):
h["X-API-Key"] = token
return h
@@ -505,7 +505,8 @@ class RetainDBMemoryProvider(MemoryProvider):
self._user_id = kwargs.get("user_id", "default") or "default"
self._agent_id = kwargs.get("agent_id", "hermes") or "hermes"
hermes_home_path = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
from hermes_constants import get_hermes_home
hermes_home_path = get_hermes_home()
db_path = hermes_home_path / "retaindb_queue.db"
self._queue = _WriteQueue(self._client, db_path)
@@ -649,11 +650,11 @@ class RetainDBMemoryProvider(MemoryProvider):
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
if not self._client:
return json.dumps({"error": "RetainDB not initialized"})
return tool_error("RetainDB not initialized")
try:
return json.dumps(self._dispatch(tool_name, args))
except Exception as exc:
return json.dumps({"error": str(exc)})
return tool_error(str(exc))
def _dispatch(self, tool_name: str, args: dict) -> Any:
c = self._client
+47 -2
View File
@@ -17,7 +17,7 @@ Or manually:
```bash
hermes config set memory.provider supermemory
echo 'SUPERMEMORY_API_KEY=your-key-here' >> ~/.hermes/.env
echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
```
## Config
@@ -26,15 +26,23 @@ Config file: `$HERMES_HOME/supermemory.json`
| Key | Default | Description |
|-----|---------|-------------|
| `container_tag` | `hermes` | Container tag used for search and writes |
| `container_tag` | `hermes` | Container tag used for search and writes. Supports `{identity}` template for profile-scoped tags (e.g. `hermes-{identity}``hermes-coder`). |
| `auto_recall` | `true` | Inject relevant memory context before turns |
| `auto_capture` | `true` | Store cleaned user-assistant turns after each response |
| `max_recall_results` | `10` | Max recalled items to format into context |
| `profile_frequency` | `50` | Include profile facts on first turn and every N turns |
| `capture_mode` | `all` | Skip tiny or trivial turns by default |
| `search_mode` | `hybrid` | Search mode: `hybrid` (profile + memories), `memories` (memories only), `documents` (documents only) |
| `entity_context` | built-in default | Extraction guidance passed to Supermemory |
| `api_timeout` | `5.0` | Timeout for SDK and ingest requests |
### Environment Variables
| Variable | Description |
|----------|-------------|
| `SUPERMEMORY_API_KEY` | API key (required) |
| `SUPERMEMORY_CONTAINER_TAG` | Override container tag (takes priority over config file) |
## Tools
| Tool | Description |
@@ -52,3 +60,40 @@ When enabled, Hermes can:
- store cleaned conversation turns after each completed response
- ingest the full session on session end for richer graph updates
- expose explicit tools for search, store, forget, and profile access
## Profile-Scoped Containers
Use `{identity}` in the `container_tag` to scope memories per Hermes profile:
```json
{
"container_tag": "hermes-{identity}"
}
```
For a profile named `coder`, this resolves to `hermes-coder`. The default profile resolves to `hermes-default`. Without `{identity}`, all profiles share the same container.
## Multi-Container Mode
For advanced setups (e.g. OpenClaw-style multi-workspace), you can enable custom container tags so the agent can read/write across multiple named containers:
```json
{
"container_tag": "hermes",
"enable_custom_container_tags": true,
"custom_containers": ["project-alpha", "project-beta", "shared-knowledge"],
"custom_container_instructions": "Use project-alpha for coding tasks, project-beta for research, and shared-knowledge for team-wide facts."
}
```
When enabled:
- `supermemory_search`, `supermemory_store`, `supermemory_forget`, and `supermemory_profile` accept an optional `container_tag` parameter
- The tag must be in the whitelist: primary container + `custom_containers`
- Automatic operations (turn sync, prefetch, memory write mirroring, session ingest) always use the **primary** container only
- Custom container instructions are injected into the system prompt
## Support
- [Supermemory Discord](https://supermemory.link/discord)
- [support@supermemory.com](mailto:support@supermemory.com)
- [supermemory.ai](https://supermemory.ai)

Some files were not shown because too many files have changed in this diff Show More