User pushback: "можно нашего Кейси подключить к обсидиан? будет в
онлайне строить граф из всех наших агентов?"
Closer-to-question architecture: don't build new Obsidian plugin —
re-use the legacy `~/Projects/lbm-graph-viz/` D3 viewer (lineage:
keicode → living-graph → lbm → lbm-graph-viz → keisei-graph). Strip
its Hebbian/co-change edges, replace with DNA-derived edges from the
kei-registry + kei-ledger. Open in any browser, file://...index.html.
NEW Rust crate `_primitives/_rust/kei-graph-export/` (~440 LOC, 5 files)
Reads:
~/.claude/registry.sqlite (730 active blocks)
~/.claude/agents/ledger.sqlite (6 agents post-cleanup)
_manifests/*.toml (38 agent manifests)
Emits 581-node, 291-edge graph. Edge types:
block_dep 171 manifest → atom (blocks=[])
path_ref 99 manifest → atom (path:NAME refs)
branch_lineage 11 parent_branch → branch
agent_uses_manifest 10 agent → manifest (slug from branch name)
Output formats:
--format spaces-fragment → `window.RUNTIME_SPACE = {...}` JS file
--format json → raw {nodes, links} for downstream tools
Block-name lookup is multi-resolution: each block is registered under
display name + lowercased + file-stem slug (from path basename) so
manifest references like `blocks = ["baseline"]` resolve to a registry
row whose `name` column holds "BASELINE — inherit from Main Claude".
Without this fix the graph had 0 block_dep edges; with it, 171.
NEW background updater `hooks/graph-export-watcher.sh` + launchd plist
template `_primitives/templates/io.keisei.graph-export.plist`
5-second loop:
while true; do
kei-graph-export --format spaces-fragment --output <viz>/data-runtime.js.tmp
mv <viz>/data-runtime.js.tmp <viz>/data-runtime.js # atomic
sleep 5
done
launchd plist substitutes `HOME_DIR` and `HOOKS_DIR` placeholders at
install time. RunAtLoad=true, KeepAlive=true. Logs to
~/.claude/memory/graph-export.log. Bypass: GRAPH_EXPORT_BYPASS=1.
Loaded into user-side launchd (PID 16474 confirmed running). File
mtime advances every 5s — live updates verified.
PATCH `~/Projects/lbm-graph-viz/index.html` (outside kit, surgical)
Three changes:
1. Add `<script src="data-runtime.js">` BEFORE `spaces.js` (window
global available when SPACES is defined).
2. After spaces.js: `if (window.RUNTIME_SPACE) SPACES.runtime = window.RUNTIME_SPACE;`
3. Auto-refresh setInterval(5s): fetch data-runtime.js, eval (re-
assigns window.RUNTIME_SPACE), hash-compare, re-render via
`rebuildGraph()` if currently viewing the runtime space.
window.RUNTIME_SPACE (not const RUNTIME_SPACE) avoids the
"const cannot be re-declared" error on subsequent eval() calls.
Effect: open file://~/Projects/lbm-graph-viz/index.html in any
browser, switch to "Runtime" space — full DNA graph of every agent /
atom / skill / branch / manifest / hook / primitive / rule, force-
laid-out by D3. Updates every 5 seconds without page reload.
What this does NOT do (deferred):
- Obsidian mirror — separate work, would emit .md per node into
~/Projects/KeiSeiVault/. Useful for backlinks navigation but
file-watcher latency similar to current 5s polling.
- Skill-invocation edges — table is empty until next Skill tool
use; will populate naturally.
- Scoped queries (orphan finder, hot-path PageRank). Out of scope
for v1; the JSON --format export feeds any downstream tool.
- `agent_uses_manifest` heuristic warns on unknown subagent slugs
(e.g. `physics-deriver` with no manifest yet). Non-fatal.
=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
- Obsidian vault mirror (Phase C, separate work)
- Skill-edges populate from real Skill use (not blockered)
- Hot-path PageRank highlighting in viewer (cosmetic)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
4.1 KiB
Rust
140 lines
4.1 KiB
Rust
use crate::nodes::{AgentInfo, ManifestInfo};
|
|
use crate::types::{sanitize_id, Edge};
|
|
use anyhow::Result;
|
|
use rusqlite::Connection;
|
|
use std::collections::HashMap;
|
|
|
|
// Edge rule 1: manifest → block
|
|
pub fn edges_manifest_block(
|
|
manifests: &[ManifestInfo],
|
|
lookup: &HashMap<String, String>,
|
|
links: &mut Vec<Edge>,
|
|
) {
|
|
for m in manifests {
|
|
for name in &m.blocks {
|
|
if let Some(id) = lookup.get(name.as_str()) {
|
|
links.push(edge(&m.node_id, id, "block_dep", 1.0));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Edge rule 2: manifest → path-atom
|
|
pub fn edges_manifest_path_ref(
|
|
manifests: &[ManifestInfo],
|
|
lookup: &HashMap<String, String>,
|
|
links: &mut Vec<Edge>,
|
|
) {
|
|
for m in manifests {
|
|
for name in &m.path_refs {
|
|
if let Some(id) = lookup.get(name.as_str()) {
|
|
links.push(edge(&m.node_id, id, "path_ref", 0.5));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Edge rule 3: manifest → rule block
|
|
pub fn edges_manifest_rule(
|
|
manifests: &[ManifestInfo],
|
|
lookup: &HashMap<String, String>,
|
|
links: &mut Vec<Edge>,
|
|
) {
|
|
for m in manifests {
|
|
for name in &m.rule_blocks {
|
|
if let Some(id) = lookup.get(name.as_str()) {
|
|
links.push(edge(&m.node_id, id, "rule_dep", 0.7));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Edge rule 4: branch lineage
|
|
pub fn edges_branch_lineage(led: &Connection, links: &mut Vec<Edge>) -> Result<()> {
|
|
let mut stmt = led.prepare(
|
|
"SELECT branch, parent_branch FROM agents \
|
|
WHERE branch IS NOT NULL AND parent_branch IS NOT NULL AND parent_branch != ''",
|
|
)?;
|
|
let rows = stmt.query_map([], |r| {
|
|
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
|
|
})?;
|
|
for row in rows {
|
|
let (branch, parent) = row?;
|
|
links.push(edge(
|
|
&format!("branch::{}", sanitize_id(&parent)),
|
|
&format!("branch::{}", sanitize_id(&branch)),
|
|
"branch_lineage", 1.0,
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Edge rule 5: agent fork
|
|
pub fn edges_agent_fork(agents: &[AgentInfo], links: &mut Vec<Edge>) {
|
|
for a in agents {
|
|
if let Some(pid) = &a.fork_parent_id {
|
|
links.push(edge(&sanitize_id(pid), &a.node_id, "agent_fork", 1.0));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Edge rule 6: skill invocation
|
|
pub fn edges_skill_invocation(led: &Connection, links: &mut Vec<Edge>) -> Result<()> {
|
|
let mut stmt = led.prepare(
|
|
"SELECT agent_id, skill_name FROM skill_invocations \
|
|
WHERE agent_id IS NOT NULL AND skill_name IS NOT NULL",
|
|
)?;
|
|
let rows = stmt.query_map([], |r| {
|
|
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
|
|
})?;
|
|
for row in rows {
|
|
let (agent_id, skill_name) = row?;
|
|
links.push(edge(
|
|
&sanitize_id(&agent_id),
|
|
&format!("skill::{}", sanitize_id(&skill_name)),
|
|
"skill_run", 0.5,
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Edge rule 7: agent → manifest (heuristic)
|
|
pub fn edges_agent_manifest(
|
|
agents: &[AgentInfo],
|
|
manifests: &[ManifestInfo],
|
|
links: &mut Vec<Edge>,
|
|
) {
|
|
for a in agents {
|
|
let slug = subagent_slug(&a.branch);
|
|
if let Some(m) = manifests.iter().find(|m| m.name == slug) {
|
|
links.push(edge(&a.node_id, &m.node_id, "agent_uses_manifest", 1.0));
|
|
} else if !slug.is_empty() {
|
|
eprintln!("warn: no manifest for slug '{}' (branch: {})", slug, a.branch);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn subagent_slug(branch: &str) -> String {
|
|
let part = branch.split('/').last().unwrap_or(branch);
|
|
let stripped = strip_trailing_digits_and_dashes(part);
|
|
let stripped = stripped.strip_prefix("inline-").unwrap_or(stripped);
|
|
stripped.to_string()
|
|
}
|
|
|
|
fn strip_trailing_digits_and_dashes(s: &str) -> &str {
|
|
let mut end = s.len();
|
|
let bytes = s.as_bytes();
|
|
while end > 0 && (bytes[end - 1].is_ascii_digit() || bytes[end - 1] == b'-') {
|
|
end -= 1;
|
|
}
|
|
s[..end].trim_end_matches('-')
|
|
}
|
|
|
|
fn edge(src: &str, tgt: &str, kind: &str, weight: f32) -> Edge {
|
|
Edge {
|
|
source: src.to_string(),
|
|
target: tgt.to_string(),
|
|
kind: kind.to_string(),
|
|
weight,
|
|
}
|
|
}
|