Skip to content

feat: knowledge-graph dashboard plugin (table view + obsidian indexer) - #4

Merged
SirMinionBot merged 260 commits into
mainfrom
feat/kg-plugin-shell
Jul 6, 2026
Merged

feat: knowledge-graph dashboard plugin (table view + obsidian indexer)#4
SirMinionBot merged 260 commits into
mainfrom
feat/kg-plugin-shell

Conversation

@SirMinionBot

Copy link
Copy Markdown
Owner

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

  • Re-indexes a local Obsidian vault on demand (or at first request).
  • Renders every node as a row in a sortable table (title, source, kind, updated, in/out degree).
  • Click a row to open a detail panel with preview, metadata, and edges.
  • Toggle sources on/off; debounced client-side search.
  • Reindex button with progress polling.
  • Defensive shims for older SDK hosts (uses 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 FastAPI APIRouter auto-mounted at /api/plugins/knowledge-graph/ and the frontend is a plain IIFE consuming window.__HERMES_PLUGIN_SDK__.

plugins/knowledge-graph/dashboard/
├── manifest.json          # /knowledge tab after:kanban, Network icon
├── plugin_api.py          # SQLite + Pydantic models + async reindex jobs
├── dist/index.js          # IIFE, no build step
├── dist/style.css         # theme-aware tokens, .hermes-kg-* prefix
└── README.md

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 ibid dashboard (:9119) with vault /home/ibid/proyectos/codigosinsiesta (24 .md files):

  • GET /health{"status":"ok",...}
  • GET /sources → 24 nodes indexed in ~1s
  • GET /search?q=mgrep → 1 hit, correct metadata + tags
  • Table view: 24 rows, sortable, badges coloured by source
  • Detail panel: full preview + 5 tags + edges

Sources (roadmap)

  • obsidian: ✅ phase 1
  • gbrain: 🚧 planned (will use gbrain MCP)
  • hermes: 🚧 planned (will walk ~/.hermes/skills/ and plugins/)

Views (roadmap)

  • Table: ✅ phase 1
  • Graph: 🚧 Cytoscape.js placeholder
  • Timeline: 🚧 vis-timeline placeholder

cc @TellMeAlex

teknium1 and others added 30 commits June 29, 2026 02:42
…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.
xxxigm and others added 26 commits June 30, 2026 19:11
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>
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
SirMinionBot merged commit f5f3f72 into main Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.