feat: knowledge-graph dashboard plugin (table view + obsidian indexer) - #4
Merged
Conversation
…module reload races (NousResearch#54775) The five _resolved_api_call_stale_timeout_base integration tests reloaded hermes_cli.config + hermes_cli.timeouts via importlib.reload to clear cached config. Under xdist that mutates module-global state shared across the worker process, so a sibling test could leave the config cache in a state that made get_provider_stale_timeout return a leaked value — intermittently failing test_reasoning_floor_applies_to_opus_4_thinking (shard 6 flake, NousResearch#52217 area). Patch run_agent.get_provider_stale_timeout per-test instead: floor-path tests get None (resolver falls through to the reasoning floor / env var / default), the explicit-config test gets 60.0 (priority-1 short-circuit). Same assertions, no shared-module mutation, deterministic under parallel execution.
… load When the dashboard gateway has no local session cookie, it rendered a click-through /login interstitial — even though the Nous portal's /oauth/authorize auto-approves any current member of the dashboard's org and is a silent 302 when the user already holds a portal session. For the common case (clicking a hosted-agent dashboard link while signed in to the portal) that interstitial click is pure friction. This makes the gate auto-initiate the OAuth redirect on an unauthenticated HTML document load instead of rendering the interstitial, when exactly one interactive provider is registered. A one-shot loop-guard cookie (hermes_sso_attempt, 60s TTL) ensures that a genuinely absent portal session (the portal bounces back still-unauthenticated) falls back to the /login page after exactly one bounce rather than ping-ponging forever. The marker is cleared on a successful callback and whenever the gate falls back to /login. Security: this removes a human CLICK, not a security check. The redirect lands on the existing /auth/login route and runs the unchanged PKCE auth-code flow; token verification, audience checks, redirect-URI match, and org-membership checks are all untouched. /api/* fetches still get the 401 JSON envelope (never a 302 a fetch() would follow opaquely), and with two or more providers the /login chooser still renders. Phase 1 of the cloud-auto-discovery work.
list_session_providers() already filters on supports_session=True, so the new helper re-filtered an already-filtered list. Call it directly at the single auto-SSO call site.
…NSERT OR IGNORE The gateway's get_or_create_session() creates a bare session row (source + user_id) before the agent exists. The agent's later create_session() carries the real model/model_config/system_prompt, but _insert_session_row used INSERT OR IGNORE — silently dropping that enrichment. Gateway sessions were left with NULL model and NULL billing metadata. Switch to INSERT ... ON CONFLICT(id) DO UPDATE with COALESCE so NULL columns get backfilled while values an earlier writer already set are never overwritten (a later bare write with source='unknown' can't clobber a real source/model). Credit: original report and fix direction by @LucidPaths (NousResearch#5048).
…dge path guard Salvages the two still-valid hardenings from NousResearch#5381 onto the relocated plugin adapters (the discord/feishu/whatsapp adapters moved to plugins/platforms/ since the PR was opened, and 4 of its 6 hunks are already on main or superseded). - feishu: rate limiter now denies untracked keys when the tracking table is at capacity after pruning stale entries (was: allow through without tracking). At-capacity-with-all-fresh-entries only happens under abuse, so allowing untracked requests let an attacker who flooded the table bypass the limiter entirely. Already-tracked keys and post-prune room are unaffected. - whatsapp: absolute file paths handed back by the Baileys bridge are now validated to resolve inside a known media cache dir before being attached. A compromised/buggy bridge could otherwise return an arbitrary path (e.g. /etc/passwd) that would be sent verbatim to the model. Guard resolves symlinks and accepts both the canonical cache/<kind> and legacy <kind>_cache layouts.
reset_had_activity gated on entry.total_tokens, which is never written (token counts migrated to agent-direct persistence) so it was always 0. That suppressed session-reset notifications for sessions that genuinely had activity. Switch to last_prompt_tokens, which is updated on every turn.
The reset-had-activity tests set total_tokens (dead state) to simulate activity; production records activity via last_prompt_tokens. Update the fixtures to match the field the fix and runtime actually use.
…ersal Session IDs can originate from untrusted input (e.g. the X-Hermes-Session-Id API header) and are interpolated raw into on-disk artifact filenames under ~/.hermes/sessions/. A traversal-shaped ID (../../../../etc/pwned) would let a caller write the session snapshot or request dump outside the sessions directory. _safe_session_filename_component() collapses every non [A-Za-z0-9_-] character to _, caps the length, and appends a short content hash when sanitization changed the string, always yielding a single traversal-free path segment. Closes NousResearch#5958.
…undary Defense-in-depth on top of _safe_session_filename_component (NousResearch#5958): Sink (makes the bad write impossible regardless of entry point): - run_agent._save_session_log: sanitize session_id before building the session_{sid}.json snapshot path. - agent_runtime_helpers.dump_api_request_debug: sanitize before building the request_dump_{sid}_{ts}.json path. Boundary (clean 400 instead of a silently-hashed filename): - api_server rejects path-traversal-shaped X-Hermes-Session-Id on the session-continuation path and the explicit /api/sessions create path, reusing gateway.session._is_path_unsafe (mirrors the native gateway's entry-boundary guard). Also enforces the session-header length cap on the continuation path. Tests: traversal session_id stays contained at the write site; sanitizer always yields a traversal-free segment; the API header rejects ../, absolute, and Windows-traversal IDs with 400.
Widen NousResearch#5961's _format_untrusted_prompt_value coverage to the Matrix room display name (**Matrix Room:**), a sibling attacker-controllable field the original fix missed. chat_name is user-settable, so an injected room name could render as literal markdown in the system prompt. Adds a regression test.
NousResearch#54834) The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed.
NVIDIA integrate.api.nvidia.com models such as minimaxai/minimax-m3 can return HTTP 200 with empty choices when max_tokens is omitted. Keep the output cap on auxiliary chat-completions routes, matching the main NVIDIA provider profile behavior.
Let users click the status bar context indicator to see how tokens are split across system prompt, tools, rules, skills, MCP, and conversation. Co-authored-by: Cursor <cursoragent@cursor.com>
…ontext-usage-popover feat(desktop): add context usage breakdown popover
…sResearch#7779) (NousResearch#54862) A manually-installed venv inside the cloned repo can be destroyed by the agent running a relative-path command against its own checkout (rm -rf venv, uv venv venv, etc.), silently wiping the running runtime mid-session. Moving the canonical manual-install venv to ~/.hermes/venvs/hermes-dev means no relative path from the agent's workspace resolves to its own runtime, making the bug class impossible without any command-detection code. Closes the root cause of NousResearch#7779. The managed install.sh layout is unchanged.
…ousResearch#54843) * feat(web_extract): truncate-and-store instead of LLM summarization web_extract no longer runs an auxiliary LLM over scraped pages. The extract backends (Firecrawl/Tavily/Exa/Parallel) already return clean, boilerplate- stripped markdown, so we return it directly: pages within a char budget (default 15000, web.extract_char_limit) come back whole; larger pages get a head+tail window plus an explicit footer giving the stored full-text path and the read_file call to page through the omitted middle. The full clean text is written to cache/web (mounted read-only into remote backends like the other cache dirs), so nothing is lost. Inline base64 images are converted to [IMAGE: alt] placeholders (token bombs dropped) while real http(s) image URLs are preserved as links so the agent can still web_extract/vision_analyze them. Removes process_content_with_llm + the chunked summarizer + check_auxiliary_model + _resolve_web_extract_auxiliary. context_references._default_url_fetcher is updated to the truncate path and its stale data.documents shape read is fixed to results (it was silently returning empty). Live before/after eval (firecrawl, 4 URLs): 11.7x faster overall (176.6s -> 15.1s); 10-60x on large pages. Quality identical; findability 4/4 (answer recoverable from stored full text on every truncated page). web_search is unchanged. No own scraper added; no changes to web_search. * fix(web_extract): add char_limit to execute_code web_extract stub The new web_extract char_limit param must appear in the code_execution_tool _TOOL_STUBS signature (and doc line) or test_stubs_cover_all_schema_params fails — the stub schema must cover every real schema param.
Subagent session pop-outs (`watch=1`) spectate a run driven elsewhere, so editing/steering the transcript from there makes no sense. Gate the composer and the user-bubble mutations on `isWatchWindow()`: - hide the composer (folds into `showChatBar`) - user prompts become a read-only button that toggles the 2-line clamp so long prompts stay fully readable, instead of opening the edit composer - drop the stop/restore actions and the checkpoint branch-picker Keyed off the narrow `isWatchWindow()` (not `isSecondaryWindow()`), so the new-session and cmd-click pop-outs are unaffected.
…atch-readonly feat(desktop): read-only spectator transcript for subagent watch windows
The Gateway item is the only statusbar entry with variant === 'menu'. Since da73223 wrapped every render branch in `Tip`, the menu branch nested `<DropdownMenu>` (a Radix Root that renders no DOM node) inside `Tip`'s `<TooltipTrigger asChild>`. With no element to attach to, Radix could never wire hover listeners, so the tooltip silently never showed. `Tip` also can't be moved inside `DropdownMenuTrigger asChild` (the shape proposed in NousResearch#54859): it's a plain component, not a Slot-forwarding one, so the trigger's injected ref/handlers would land on `TooltipContent` instead of the button and break the menu's click + popper anchoring. Fix by composing both trigger Slots directly onto a single <button> (`TooltipTrigger asChild` over `DropdownMenuTrigger asChild`), the pattern already used in profile-switcher.tsx, and skip the tooltip wrapper entirely when the item has no title. Supersedes NousResearch#54859. Co-authored-by: wnuuee1 <wnuuee1@users.noreply.github.com>
…tatusbar-tooltip fix(desktop): show Gateway statusbar tooltip via composed trigger Slots
…ains (NousResearch#54824) Add a generic suppress_notification flag to the drain-request marker. When a drain that ends in process exit (e.g. a NAS auto-update image migration on the always-on Hermes Cloud fleet) is flagged, the gateway skips ONLY the home-channel 'gateway shutting down' broadcast — the operator-flavoured ping that would otherwise fire on every routine auto-update, dozens of times a day. The per-active-session interrupt ping is ALWAYS kept: on a drained shutdown it's empty by construction, and in the force-interrupt (deadline-exceeded) case it carries the user-valuable 'your task was cut off, message me to resume' hint. The gateway stays agnostic about WHY a drain is quiet (generic boolean, not a kind enum); the policy of which drain causes set the flag lives in the caller (NAS). Default-false so legacy/operator drains behave exactly as before. The reader reuses the NS-570 epoch-staleness check so an orphaned marker on the durable volume can never silence a fresh gateway's legitimate broadcast. - drain_control.py: write_drain_request gains suppress_notification; new drain_notification_suppressed() reader (current-epoch + truthy flag). - web_server.py: /api/gateway/drain reads + echoes the flag. - run.py: _notify_active_sessions_of_shutdown skips the home-channel loop only. Tests prove: flag round-trips; home-channel suppressed when set, kept when unset; active-session ping always fires; stale/legacy/corrupt markers never suppress.
Opt-in $petRoam (localStorage), $petMotion (run/jump pose) and $petRoamDir (-1/0/1) feed the shared $petState only while the agent is at rest ($petAtRest), so a wander never overrides real activity.
roamWalkRow() prefers running-left/running-right rows, falling back to the generic running row with a mirror for pets that lack them.
usePetRoam re-measures ledges from the live DOM each beat and walks/hops/falls between them, driving DOM position imperatively (no per-frame re-render).
Tag both bars with data-slots; the roam loop stands on the status bar's top edge (not over it) and treats the profile rail as a climbable ledge.
hermes tools persists the selected model to image_gen.model, but the OpenRouter-compatible provider only read scoped image_gen.<provider>.model and ignored the dispatch model kwarg — so Nous users always hit the default quality-first chain and fell back to Gemini.
Assert image_gen.model, explicit model kwargs, and Nous provider wiring so the config path mismatch cannot regress.
…w precedence The cherry-picked fix added explicit-kwarg and top-level image_gen.model resolution but left _resolve_model / _resolve_model_chain docstrings stating the old 'env override -> config -> DEFAULT_MODEL' order. Document the full precedence (explicit kwarg -> env -> scoped -> top-level -> default chain) to match the sibling krea/openai providers.
Regression tests for stale-backend detection when projects.create is missing from an older backend that still reports the same semver.
Probe the projects.* RPC surface, block create with a clear update hint, and avoid the raw "unknown method" toast. Includes i18n for en, zh, ja, and zh-hant. Fixes NousResearch#54999
…t memory retry loops (NousResearch#42405) - replace() and remove() now return entry previews and current_entries when no entry matches old_text, matching the multi-match and add-limit error behavior - add() limit error also now returns previews for consistency - Agent can self-correct after a failed replace/remove instead of looping blindly until turn budget is exhausted with no user response
…ion failures (NousResearch#42405) Builds on the zero-match feedback fix (previous commit) to close the silent-hang symptom: when memory is at capacity, a failed `add`/`replace`/`remove` consolidation could loop the whole turn to iteration-budget exhaustion and deliver no user-facing reply. NousResearch#41755 turned the at-capacity overflow error into a *commanded* in-turn retry ("...then retry this add — all in this turn"); combined with the fragile substring-only `replace`/`remove` matching (LLMs can't reliably re-quote a long entry verbatim), the model loops add↔replace on inexact guesses until the turn dies. The existing tool_guardrails halt would catch this, but hard_stop_enabled is opt-in (off by default), so a default install still hangs. This fixes it at the memory layer without changing global guardrail behavior: - MemoryStore tracks per-turn consolidation failures; after a cap (3) it drops the "retry in this turn" instruction and returns a terminal "leave memory unchanged, continue your reply" result, so a failed memory side effect can never block the turn's reply. - The counter resets on any successful write (progress) and at each turn boundary (turn_context.reset_consolidation_failures, guarded via getattr so plugin memory stores without the method are a no-op). Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
…g chokepoint
A single 'hermes update' / 'hermes -p' could rewrite a hand-curated config.yaml
into a near-full DEFAULT_CONFIG dump (the 'you blow up my profile config on one
tweak' reports). Root cause: migrate_config() had ~16 independent save_config()
call sites, each author deciding ad hoc whether to materialise a value, and many
persisted pure schema defaults with strip_defaults=False. Defaults already merge
transparently at read time via load_config(), so writing them is pure bloat that
also shadows future default changes (see save_config's docstring).
Architectural fix (not a per-site patch): introduce a single _persist_migration()
chokepoint that enforces one invariant — a migration may persist only values that
DIFFER from the current schema default, plus explicit removals/renames of user
data; pure defaults are never written. Every migration write (all 17 sites incl.
the version-bump finalizer) now routes through it. The invariant is mechanically
correct for all cases and verified empirically:
- pure-default seeds (timezone='', curator/auxiliary.curator blocks, interim
flag, curator.consolidate=False, empty plugins.enabled) are stripped → merged
in at read time;
- non-default values (write_approval=True, model_catalog.ttl_hours=1) preserved
via explicit-raw-path preservation;
- behaviour flips (agent.verify_on_stop=False, schema default still 'auto')
preserved because False != 'auto';
- data transforms (custom_providers->providers, stt.model relocation,
write_mode->write_approval, compression.summary_* removal, MCP-disable)
persist their removals/renames.
An explicitly user-set non-default value (e.g. matrix.require_mention: false) is
preserved across the bump.
Guard tests lock the architecture: an AST check asserts migrate_config() makes no
direct save_config() call (all writes go through _persist_migration), and a
full-range v1->latest test asserts a lean config is never dumped. Two existing
change-detector tests that froze the on-disk representation of default-valued
keys are rewritten to assert the effective value via load_config() (behaviour
contract, not snapshot).
Validation: lean v1->latest migration drops from ~567 bytes to ~196 bytes;
148 config+setup and 196 profile/curator/migrate tests pass on scripts/run_tests.sh.
…igration-no-default-expansion fix(config): route every migration write through one default-stripping chokepoint
…ged-git-pull fix(installer): recover bootstrap when managed git clone diverged
…s-stale-backend fix(desktop): handle stale backend when creating projects (NousResearch#54999)
… folder desktop-onboarding-overlay.tsx (1,291 lines) folded into components/onboarding/ as a cohesive feature folder. Behaviour-preserving — every move is verbatim; typecheck + lint + tests green. - index.tsx (665) — overlay shell, Picker, Header/Preparing, API-key catalog + ApiKeyForm; re-exports the provider API the settings page consumes. - flow.tsx (364) — OAuth flow panels (FlowPanel, steps, DeviceCode, CodeBlock, ConfirmingModelPanel, DocsLink, Status). - providers.tsx (118) — provider rows + display/sort (FeaturedProviderRow, ProviderRow, KeyProviderRow, providerTitle, sortProviders). - glyph.tsx (170) — the decode/scramble animation toolkit (pure leaf). Importers (desktop-controller, providers-settings) repointed to @/components/onboarding; the overlay test moved to onboarding/index.test.tsx.
…lit-onboarding refactor(desktop): split onboarding overlay god file into onboarding/ folder
_build_gemini_contents emitted one contents entry per source message and
never merged adjacent same-role entries. Gemini's generateContent requires
strict user/model alternation and rejects consecutive same-role turns with
HTTP 400 ("Please ensure that multiturn requests alternate between user and
model"). A parallel tool call turns into two tool results in a row, which
become two consecutive user functionResponse contents, so every multi-tool
turn produced an unsendable history.
Fold adjacent same-role contents into one by concatenating their parts after
the per-message loop, matching the Anthropic and Bedrock converters. For a
parallel call this yields the grouped multi-functionResponse user turn Gemini
expects.
MoA sessions could not stream: the gateway streaming toggle was a no-op for
provider "moa", so users saw nothing until the entire response finished — minutes
of silence on long turns. The aggregator's reply was always fetched whole.
Root cause was twofold:
1. conversation_loop hard-disabled streaming for provider in {"copilot-acp",
"moa"} (MoA grouped with the ACP client, whose facade isn't a stream).
2. MoAChatCompletions.create() fetched the aggregator response whole via
call_llm(), which had no streaming mode.
For provider "moa", _create_request_openai_client() returns the MoAClient facade
itself, so the existing streaming consumer already calls
MoAChatCompletions.create(stream=True). We reuse that battle-tested consumer
(text-delta delivery, tool_call reassembly, stale-stream detection, non-streaming
fallback) instead of adding a parallel streaming path.
Changes:
- call_llm() gains stream/stream_options. When streaming it returns the raw SDK
stream iterator directly, bypassing _validate_llm_response and the
temperature/max_tokens/payment fallback chain (which assume a complete
response). The caller owns reassembly and fallback.
- MoAChatCompletions.create() runs the references first (unchanged), then when
stream=True returns the aggregator's raw stream, forwarding stream_options and
the consumer's per-request read timeout. stream=False is byte-identical to
before (no stream/stream_options/timeout forwarded).
- conversation_loop streams MoA only when a display/TTS consumer is present;
quiet/subagent/health-check paths keep the complete-response path.
Tests: tests/run_agent/test_moa_streaming.py — create() stream/non-stream
branches, stream_options + timeout forwarding, call_llm raw-stream return vs
validated non-stream. Existing MoA tests unchanged (20 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # web/src/App.tsx
Adds a new dashboard plugin that indexes an Obsidian vault into a local SQLite database and exposes the resulting nodes and edges through a FastAPI router mounted at /api/plugins/knowledge-graph/. * manifest.json: registers /knowledge tab after kanban, Network icon. * plugin_api.py: SQLite + Pydantic models, async reindex job with progress polling, Obsidian indexer with robust wikilink regex ([[Page]], [[Page#Heading]], [[Page|Alias]]), inline #tag extraction that skips code blocks and headings, frontmatter parsing, dangling-link handling, synthetic tag nodes, mtime-based incremental updates. 3/3 pytest. * dist/index.js: plain IIFE consuming window.__HERMES_PLUGIN_SDK__, sortable/filterable table view, source toggles, debounced search, slide-in detail panel with incoming/outgoing edges, reindex button with job polling. Defensive: exits silently if SDK missing. * dist/style.css: theme-aware (.hermes-kg-* prefix, semantic tokens, no hardcoded palette, min opacity 0.7 on text, min text-xs). Graph and timeline views are intentional placeholders for follow-up PRs.
The /reindex endpoint returned job_id immediately, but _run_indexer only inserted child rows named '<job_id>-<source>' into the jobs table. Clients polling the original job_id got 'job not found' indefinitely. Fix: insert the parent job row up front in post_reindex() so polling succeeds, and mark it 'done' once all source jobs finish.
The modal opened but its backdrop was nearly transparent and the panel was getting clipped by the host's stacking context, so the row and the panel showed side by side as if it were a split pane. Switch to top/left/right/bottom + 100vw/100vh + z-index:9999 + 0.72 alpha for a real modal, and listen for Escape inside the panel to close it.
GET /node/{id} returns {node, incoming, outgoing}. The client was
setting selectedNode to the whole envelope, so DetailPanel saw
node.title === undefined.
Wires the two views that were placeholders in the original plugin scaffold. Both run entirely client-side against the same nodes/edges state already loaded for the table. ## GraphView - Loads Cytoscape.js 3.30.4 (MIT) via a CDN-first, vendor-fallback loader. The vendor copy ships at dist/vendor/cytoscape.min.js so the plugin keeps working offline. - CoSE layout (animate:false, 1500 iter) sized to viewport with 30px padding and node sizes that scale with log(degree). - Node colour by source (obsidian→purple, gbrain→blue, hermes→green), text labels truncated to 40 chars with ellipsis. - Hover dims everything outside the closed neighbourhood and highlights the focused node + its edges. - Click a node to open the existing DetailPanel via openNodeDetail. ## TimelineView - Buckets nodes by updated_at (falls back to created_at) day. - Sticky day headers show weekday + ISO date and event count. - Each event row shows source badge, title, and HH:MM UTC. - Click to open the existing DetailPanel. Style additions live in dist/style.css under the .hermes-kg-graph-* and .hermes-kg-timeline-* namespaces, theme-aware via semantic tokens. Vendored Cytoscape.js retains its MIT copyright in dist/vendor/LICENSE.txt.
GraphView used useRef but the destructured import only had useState, useEffect, useMemo. ReferenceError tore down the entire IIFE on any click that swapped the active view to graph/timeline, leaving the page blank. Also verifies Cytoscape.js 3.30.4's UMD wrapper does assign globalThis.cytoscape when loaded as a plain <script> (no extra wrapping needed in the vendor bundle).
…ode/{id}/backlinks endpoint
Adds a three-tab detail panel (Overview / Outgoing / Backlinks) inside
the knowledge-graph dashboard plugin, matching Obsidian's right-side
backlinks pane behaviour.
Backend
-------
* New GET /api/plugins/knowledge-graph/node/{id}/backlinks endpoint
with limit+offset pagination (defaults: limit=100).
* JOINs kg_edges + kg_nodes so each backlink row carries the source
node's source/kind/title/path/tags/updated_at in one round trip.
* Returns total count separately so the panel can show '23 more' even
when the user only sees the first page.
Frontend
--------
* DetailPanel rewritten with three tabs (Overview/Outgoing/Backlinks)
styled to match the codigo-sin-siesta theme chrome.
* Tabs show counts in the buttons (Outgoing 3, Backlinks 12) so users
see what's behind each tab without clicking.
* Each Outgoing / Backlinks row is clickable: clicking navigates the
detail panel to that neighbour (no modal stacking).
* New useMemo titleOf map in KnowledgeGraphPage resolves node ids to
readable titles, so '→ Módulo 2: TypeScript MCP' replaces
'→ obsidian:módulo-2-typescript-mcp' in the Outgoing tab.
* Tag chips under Overview honour node.tags (was missing before).
* Lazy-fetch for the Backlinks tab — only hits the endpoint when the
user actually opens it.
CSS
---
* .hermes-kg-tabs hover state, .hermes-kg-edge-list scrollable region,
.hermes-kg-backlink-meta badge row layout. All semantic tokens
(--bg-card, --border, --accent), no hardcoded colours.
No secret/config drift; only in-process IIFE behaviour + a new router
method.
…a Map titleOf is built in KnowledgeGraphPage as a useMemo'd object mapping node.id -> title. The Outgoing tab in DetailPanel was calling it as a function titleOf(otherId), which threw 'titleOf is not a function' as soon as the user clicked any row and the panel tried to render the outgoing list (45 edges in the graphify-out report). React aborted the whole panel render, leaving <div id="root"> empty. Switch to a Map lookup: titleOf[otherId]. Verified end-to-end: clicking GRAPH_REPORT now opens the panel with Overview/Outgoing(45)/Backlinks(0) tabs and the outgoing list shows neighbour titles instead of crashing.
SirMinionBot
force-pushed
the
feat/kg-plugin-shell
branch
from
June 30, 2026 19:42
2719cd2 to
4fae380
Compare
TellMeAlex
approved these changes
Jul 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new dashboard plugin that visualises and searches personal knowledge across multiple sources. Phase 1 ships the Obsidian source end-to-end: a SQLite-backed indexer that parses wikilinks, inline tags, and frontmatter from any local vault, plus a sortable / filterable table with a slide-in detail panel that shows the node's preview, metadata, and incoming/outgoing edges.
What it does
window.__HERMES_PLUGIN_SDK__but gracefully renders against hosts missing optional components).Architecture
Following the repo's native plugin pattern (verified in
hermes_cli/web_server.py::_mount_plugin_api_routes), the backend is a FastAPIAPIRouterauto-mounted at/api/plugins/knowledge-graph/and the frontend is a plain IIFE consumingwindow.__HERMES_PLUGIN_SDK__.Configuration
KG_OBSIDIAN_VAULT: vault root (default~/proyectos/codigosinsiesta)KG_DB_PATH: SQLite location (default~/.hermes/knowledge-graph.db)Endpoints
GET /health,GET /sources,GET /graph?source=&limit=,GET /node/{id},GET /search?q=,POST /reindex,GET /reindex/{job_id}.Tests
3/3 pytest passing on
plugin_api.py: wikilink regex, inline-tag extraction (excludes code blocks + headings), end-to-end index of a synthetic vault with dangling links and tag-node synthesis.Verification
End-to-end against
ibiddashboard (:9119) with vault/home/ibid/proyectos/codigosinsiesta(24 .md files):GET /health→{"status":"ok",...}GET /sources→ 24 nodes indexed in ~1sGET /search?q=mgrep→ 1 hit, correct metadata + tagsSources (roadmap)
~/.hermes/skills/andplugins/)Views (roadmap)
cc @TellMeAlex