feat(graph): live runtime DNA viewer — kei-graph-export + lbm-graph-viz adapter
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>
This commit is contained in:
parent
d3955521d1
commit
878be87bf6
11 changed files with 644 additions and 6 deletions
12
_primitives/_rust/Cargo.lock
generated
12
_primitives/_rust/Cargo.lock
generated
|
|
@ -3663,6 +3663,18 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-graph-export"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-hibernate"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -177,6 +177,8 @@ members = [
|
|||
"kei-mcp",
|
||||
# SQL ↔ TypeScript schema drift detector
|
||||
"kei-db-contract",
|
||||
# Live runtime-graph exporter (registry + ledger → D3 space fragment)
|
||||
"kei-graph-export",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
18
_primitives/_rust/kei-graph-export/Cargo.toml
Normal file
18
_primitives/_rust/kei-graph-export/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "kei-graph-export"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Export KeiSei registry + ledger as D3 graph space fragment"
|
||||
|
||||
[[bin]]
|
||||
name = "kei-graph-export"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
140
_primitives/_rust/kei-graph-export/src/edges.rs
Normal file
140
_primitives/_rust/kei-graph-export/src/edges.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
69
_primitives/_rust/kei-graph-export/src/graph.rs
Normal file
69
_primitives/_rust/kei-graph-export/src/graph.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use crate::{edges, nodes};
|
||||
use crate::types::{Edge, Node, Space, SpaceData};
|
||||
use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct GraphConfig {
|
||||
pub registry_db: PathBuf,
|
||||
pub ledger_db: PathBuf,
|
||||
pub manifests_dir: PathBuf,
|
||||
}
|
||||
|
||||
pub fn build_graph(cfg: &GraphConfig) -> Result<Space> {
|
||||
let reg = Connection::open(&cfg.registry_db)?;
|
||||
let led = Connection::open(&cfg.ledger_db)?;
|
||||
|
||||
let (mut nodes, block_lookup) = nodes::collect_blocks(®)?;
|
||||
let agents = nodes::collect_agents(&led, &mut nodes)?;
|
||||
let manifests = nodes::collect_manifests(&cfg.manifests_dir, &mut nodes)?;
|
||||
nodes::collect_branches(&led, &mut nodes)?;
|
||||
nodes::collect_skills(&led, &mut nodes)?;
|
||||
|
||||
let mut links = Vec::new();
|
||||
edges::edges_manifest_block(&manifests, &block_lookup, &mut links);
|
||||
edges::edges_manifest_path_ref(&manifests, &block_lookup, &mut links);
|
||||
edges::edges_manifest_rule(&manifests, &block_lookup, &mut links);
|
||||
edges::edges_branch_lineage(&led, &mut links)?;
|
||||
edges::edges_agent_fork(&agents, &mut links);
|
||||
edges::edges_skill_invocation(&led, &mut links)?;
|
||||
edges::edges_agent_manifest(&agents, &manifests, &mut links);
|
||||
|
||||
compute_connections(&mut nodes, &links);
|
||||
|
||||
Ok(Space {
|
||||
name: "Runtime",
|
||||
icon: "activity",
|
||||
description: "Live KeiSei agent + atom DNA graph",
|
||||
colors: default_colors(),
|
||||
data: SpaceData { nodes, links },
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_connections(nodes: &mut Vec<Node>, links: &[Edge]) {
|
||||
let mut degree: HashMap<String, usize> = HashMap::new();
|
||||
for l in links {
|
||||
*degree.entry(l.source.clone()).or_default() += 1;
|
||||
*degree.entry(l.target.clone()).or_default() += 1;
|
||||
}
|
||||
for n in nodes.iter_mut() {
|
||||
n.connections = *degree.get(&n.id).unwrap_or(&0);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_colors() -> HashMap<String, String> {
|
||||
[
|
||||
("atom", "#6ee7b7"),
|
||||
("hook", "#f59e0b"),
|
||||
("primitive", "#60a5fa"),
|
||||
("rule", "#c084fc"),
|
||||
("skill", "#34d399"),
|
||||
("agent", "#f87171"),
|
||||
("branch", "#94a3b8"),
|
||||
("manifest", "#fb923c"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
66
_primitives/_rust/kei-graph-export/src/main.rs
Normal file
66
_primitives/_rust/kei-graph-export/src/main.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
mod types;
|
||||
mod nodes;
|
||||
mod edges;
|
||||
mod graph;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "kei-graph-export", about = "Export KeiSei graph as D3 space fragment")]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
registry_db: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
ledger_db: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
manifests_dir: Option<PathBuf>,
|
||||
|
||||
#[arg(long, default_value = "spaces-fragment")]
|
||||
format: String,
|
||||
|
||||
#[arg(long, short = 'o')]
|
||||
output: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn home_path(suffix: &str) -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(home).join(suffix)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let registry_db = cli.registry_db
|
||||
.or_else(|| std::env::var("KEI_REGISTRY_DB").ok().map(PathBuf::from))
|
||||
.unwrap_or_else(|| home_path(".claude/registry.sqlite"));
|
||||
let ledger_db = cli.ledger_db
|
||||
.or_else(|| std::env::var("KEI_LEDGER_DB").ok().map(PathBuf::from))
|
||||
.unwrap_or_else(|| home_path(".claude/agents/ledger.sqlite"));
|
||||
let manifests_dir = cli.manifests_dir
|
||||
.unwrap_or_else(|| home_path("Projects/KeiSeiKit-public/_manifests"));
|
||||
|
||||
let cfg = graph::GraphConfig { registry_db, ledger_db, manifests_dir };
|
||||
let space = graph::build_graph(&cfg)?;
|
||||
|
||||
let content = match cli.format.as_str() {
|
||||
"json" => serde_json::to_string_pretty(&space.data)?,
|
||||
// window.RUNTIME_SPACE = ... — assigning to global avoids the
|
||||
// `const RUNTIME_SPACE` re-declaration error when the auto-refresh
|
||||
// script re-evaluates this file every 5s in the browser.
|
||||
_ => format!(
|
||||
"// Auto-generated by kei-graph-export — do not edit.\n\
|
||||
window.RUNTIME_SPACE = {};\n",
|
||||
serde_json::to_string(&space)?
|
||||
),
|
||||
};
|
||||
|
||||
match cli.output {
|
||||
Some(path) => std::fs::write(path, content)?,
|
||||
None => print!("{}", content),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
218
_primitives/_rust/kei-graph-export/src/nodes.rs
Normal file
218
_primitives/_rust/kei-graph-export/src/nodes.rs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
use crate::types::{dna_prefix, sanitize_id, truncate_chars, Node};
|
||||
use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct ManifestInfo {
|
||||
pub node_id: String,
|
||||
pub name: String,
|
||||
pub blocks: Vec<String>,
|
||||
pub path_refs: Vec<String>,
|
||||
pub rule_blocks: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct AgentInfo {
|
||||
pub node_id: String,
|
||||
pub branch: String,
|
||||
pub fork_parent_id: Option<String>,
|
||||
}
|
||||
|
||||
pub fn collect_blocks(reg: &Connection) -> Result<(Vec<Node>, HashMap<String, String>)> {
|
||||
// Pull `path` too so we can derive the file-stem slug (e.g.
|
||||
// `_blocks/baseline.md` → `baseline`). Manifests reference blocks by
|
||||
// this short slug, while the `name` column stores display names like
|
||||
// "BASELINE — inherit from Main Claude". The lookup must accept both.
|
||||
let mut stmt = reg.prepare(
|
||||
"SELECT dna, block_type, name, path FROM blocks WHERE superseded_by IS NULL",
|
||||
)?;
|
||||
let mut nodes = Vec::new();
|
||||
let mut lookup: HashMap<String, String> = HashMap::new();
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (dna, btype, name, path) = row?;
|
||||
let id = dna_prefix(&dna);
|
||||
// Primary lookup: full display name (e.g. used by some manifests).
|
||||
lookup.insert(name.clone(), id.clone());
|
||||
// Secondary lookup: lowercased name (case-insensitive resolve).
|
||||
lookup.insert(name.to_lowercase(), id.clone());
|
||||
// Tertiary lookup: file-stem slug — the canonical reference form
|
||||
// in `_manifests/*.toml` blocks=[] / rule_blocks=[].
|
||||
if let Some(stem) = std::path::Path::new(&path)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
{
|
||||
lookup.insert(stem.to_string(), id.clone());
|
||||
lookup.insert(stem.to_lowercase(), id.clone());
|
||||
}
|
||||
nodes.push(Node {
|
||||
id,
|
||||
title: format!("{}: {}", btype, truncate_chars(&name, 60)),
|
||||
kind: btype.clone(),
|
||||
category: btype,
|
||||
tags: vec![],
|
||||
connections: 0,
|
||||
extra: HashMap::new(),
|
||||
});
|
||||
}
|
||||
Ok((nodes, lookup))
|
||||
}
|
||||
|
||||
type AgentRow = (String, Option<String>, Option<String>, Option<String>,
|
||||
Option<String>, Option<String>, Option<i64>, Option<i64>,
|
||||
Option<i64>, Option<String>);
|
||||
|
||||
pub fn collect_agents(led: &Connection, nodes: &mut Vec<Node>) -> Result<Vec<AgentInfo>> {
|
||||
let mut stmt = led.prepare(
|
||||
"SELECT id, branch, parent_branch, model, outcome, status, \
|
||||
cost_micro_cents, tokens_in, tokens_out, fork_parent_id FROM agents",
|
||||
)?;
|
||||
let mut agents = Vec::new();
|
||||
let rows = stmt.query_map([], |r| Ok((
|
||||
r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?,
|
||||
r.get::<_, Option<String>>(2)?, r.get::<_, Option<String>>(3)?,
|
||||
r.get::<_, Option<String>>(4)?, r.get::<_, Option<String>>(5)?,
|
||||
r.get::<_, Option<i64>>(6)?, r.get::<_, Option<i64>>(7)?,
|
||||
r.get::<_, Option<i64>>(8)?, r.get::<_, Option<String>>(9)?,
|
||||
)))?;
|
||||
for row in rows {
|
||||
let info = push_agent_node(row?, nodes);
|
||||
agents.push(info);
|
||||
}
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
fn push_agent_node(row: AgentRow, nodes: &mut Vec<Node>) -> AgentInfo {
|
||||
let (id, branch, _par, model, outcome, status, cost, tin, tout, fork_pid) = row;
|
||||
let branch = branch.unwrap_or_default();
|
||||
let model_s = model.unwrap_or_default();
|
||||
let outcome_s = outcome.unwrap_or_default();
|
||||
let status_s = status.unwrap_or_default();
|
||||
let cost_usd = cost.unwrap_or(0) as f64 / 1_000_000.0;
|
||||
let node_id = sanitize_id(&id);
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert("model".to_string(), model_s.clone());
|
||||
extra.insert("cost_usd".to_string(), format!("{:.4}", cost_usd));
|
||||
extra.insert("outcome".to_string(), outcome_s.clone());
|
||||
extra.insert("status".to_string(), status_s.clone());
|
||||
extra.insert("tokens_in".to_string(), tin.unwrap_or(0).to_string());
|
||||
extra.insert("tokens_out".to_string(), tout.unwrap_or(0).to_string());
|
||||
nodes.push(Node {
|
||||
id: node_id.clone(),
|
||||
title: format!("agent: {} ({}, {})", truncate_chars(&branch, 40), model_s, outcome_s),
|
||||
kind: "agent".to_string(),
|
||||
category: model_s,
|
||||
tags: vec![outcome_s, status_s],
|
||||
connections: 0,
|
||||
extra,
|
||||
});
|
||||
AgentInfo { node_id, branch, fork_parent_id: fork_pid }
|
||||
}
|
||||
|
||||
pub fn collect_manifests(dir: &PathBuf, nodes: &mut Vec<Node>) -> Result<Vec<ManifestInfo>> {
|
||||
let mut manifests = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return Ok(manifests) };
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("toml") { continue; }
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let val: toml::Value = toml::from_str(&content)
|
||||
.unwrap_or(toml::Value::Table(Default::default()));
|
||||
let blocks = extract_str_array(&val, "blocks");
|
||||
let rule_blocks = extract_str_array(&val, "rule_blocks");
|
||||
let path_refs = extract_path_refs(&val);
|
||||
let substrate = val.get("substrate_role")
|
||||
.and_then(|v| v.as_str()).unwrap_or("agent").to_string();
|
||||
let node_id = format!("manifest::{}", sanitize_id(&stem));
|
||||
nodes.push(Node {
|
||||
id: node_id.clone(),
|
||||
title: format!("manifest: {}", stem),
|
||||
kind: "manifest".to_string(),
|
||||
category: substrate,
|
||||
tags: vec![],
|
||||
connections: 0,
|
||||
extra: HashMap::new(),
|
||||
});
|
||||
manifests.push(ManifestInfo { node_id, name: stem, blocks, path_refs, rule_blocks });
|
||||
}
|
||||
Ok(manifests)
|
||||
}
|
||||
|
||||
pub fn collect_branches(led: &Connection, nodes: &mut Vec<Node>) -> Result<()> {
|
||||
let mut stmt = led.prepare(
|
||||
"SELECT branch, parent_branch FROM agents WHERE branch IS NOT NULL",
|
||||
)?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, Option<String>>(0)?, r.get::<_, Option<String>>(1)?))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (branch, parent) = row?;
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
if let Some(b) = branch { candidates.push(b); }
|
||||
if let Some(p) = parent { candidates.push(p); }
|
||||
for b in candidates {
|
||||
if seen.insert(b.clone()) {
|
||||
nodes.push(Node {
|
||||
id: format!("branch::{}", sanitize_id(&b)),
|
||||
title: format!("branch: {}", truncate_chars(&b, 50)),
|
||||
kind: "branch".to_string(),
|
||||
category: "branch".to_string(),
|
||||
tags: vec![],
|
||||
connections: 0,
|
||||
extra: HashMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn collect_skills(led: &Connection, nodes: &mut Vec<Node>) -> Result<()> {
|
||||
let mut stmt = led.prepare(
|
||||
"SELECT DISTINCT skill_name FROM skill_invocations WHERE skill_name IS NOT NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
for row in rows {
|
||||
let name = row?;
|
||||
nodes.push(Node {
|
||||
id: format!("skill::{}", sanitize_id(&name)),
|
||||
title: format!("skill: {}", name),
|
||||
kind: "skill".to_string(),
|
||||
category: "skill".to_string(),
|
||||
tags: vec![],
|
||||
connections: 0,
|
||||
extra: HashMap::new(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_str_array(val: &toml::Value, key: &str) -> Vec<String> {
|
||||
val.get(key).and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn extract_path_refs(val: &toml::Value) -> Vec<String> {
|
||||
let extras = val.get("references")
|
||||
.and_then(|r| r.get("extra"))
|
||||
.and_then(|e| e.as_array());
|
||||
let Some(arr) = extras else { return vec![] };
|
||||
arr.iter().filter_map(|v| v.as_str())
|
||||
.filter(|s| s.starts_with("path:"))
|
||||
.map(|s| {
|
||||
let after = &s["path:".len()..];
|
||||
after.split('/').next().unwrap_or(after).to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
54
_primitives/_rust/kei-graph-export/src/types.rs
Normal file
54
_primitives/_rust/kei-graph-export/src/types.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Node {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub category: String,
|
||||
pub tags: Vec<String>,
|
||||
pub connections: usize,
|
||||
pub extra: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Edge {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub weight: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpaceData {
|
||||
pub nodes: Vec<Node>,
|
||||
pub links: Vec<Edge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Space {
|
||||
pub name: &'static str,
|
||||
pub icon: &'static str,
|
||||
pub description: &'static str,
|
||||
pub colors: HashMap<String, String>,
|
||||
pub data: SpaceData,
|
||||
}
|
||||
|
||||
pub fn sanitize_id(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| if c.is_alphanumeric() || "-_:/.".contains(c) { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn dna_prefix(dna: &str) -> String {
|
||||
sanitize_id(&dna.chars().take(30).collect::<String>())
|
||||
}
|
||||
|
||||
pub fn truncate_chars(s: &str, max: usize) -> &str {
|
||||
if s.chars().count() <= max { return s; }
|
||||
let end = s.char_indices().nth(max).map(|(i, _)| i).unwrap_or(s.len());
|
||||
&s[..end]
|
||||
}
|
||||
35
_primitives/templates/io.keisei.graph-export.plist
Normal file
35
_primitives/templates/io.keisei.graph-export.plist
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?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>io.keisei.graph-export</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/sh</string>
|
||||
<string>HOOKS_DIR/graph-export-watcher.sh</string>
|
||||
</array>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>HOME_DIR/.claude/memory/graph-export.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>HOME_DIR/.claude/memory/graph-export.log</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:HOME_DIR/.cargo/bin</string>
|
||||
<key>HOME</key>
|
||||
<string>HOME_DIR</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
# KeiSeiKit DNA Encyclopedia
|
||||
|
||||
> Auto-generated from kei-registry. Last regenerated: 2026-05-01T19:42:09Z.
|
||||
> Total blocks: 515. Per-type breakdown:
|
||||
> Auto-generated from kei-registry. Last regenerated: 2026-05-02T05:07:21Z.
|
||||
> Total blocks: 518. Per-type breakdown:
|
||||
|
||||
| Type | Count |
|
||||
|---|---:|
|
||||
| atom | 121 |
|
||||
| hook | 43 |
|
||||
| primitive | 109 |
|
||||
| hook | 45 |
|
||||
| primitive | 110 |
|
||||
| rule | 174 |
|
||||
| skill | 68 |
|
||||
|
||||
---
|
||||
|
||||
## Primitive (109)
|
||||
## Primitive (110)
|
||||
|
||||
Sorted alphabetically by name.
|
||||
|
||||
|
|
@ -63,6 +63,7 @@ Sorted alphabetically by name.
|
|||
| kei-git-gitea | primitive::md,networ… | _primitives/_rust/kei-git-gitea/Cargo.toml | 0de210a2 |
|
||||
| kei-git-gitlab | primitive::md,networ… | _primitives/_rust/kei-git-gitlab/Cargo.toml | 59a5271b |
|
||||
| kei-graph-check | primitive::cli,fs,md… | _primitives/_rust/kei-graph-check/Cargo.toml | 2c0e38d8 |
|
||||
| kei-graph-export | primitive::cli,md,sq… | _primitives/_rust/kei-graph-export/Cargo.toml | de93b403 |
|
||||
| kei-hibernate | primitive::cli,hash,… | _primitives/_rust/kei-hibernate/Cargo.toml | 1ea136f5 |
|
||||
| kei-import-project | primitive::cli,fs,ha… | _primitives/_rust/kei-import-project/Cargo.toml | 2de0fd64 |
|
||||
| kei-leak-matrix | primitive::cli,fs,md… | _primitives/_rust/kei-leak-matrix/Cargo.toml | a3803ef9 |
|
||||
|
|
@ -838,7 +839,7 @@ Sorted alphabetically by name.
|
|||
| sleep-layer::the-rule | rule::_::576bbb7f::d… | d0e03a0d |
|
||||
|
||||
|
||||
## Hook (43)
|
||||
## Hook (45)
|
||||
|
||||
Sorted alphabetically by name.
|
||||
|
||||
|
|
@ -867,6 +868,7 @@ Sorted alphabetically by name.
|
|||
| disk-reclaim | shell | hook::shell::47b7bf4… | hooks/disk-reclaim.sh |
|
||||
| error-spike-detector | shell | hook::shell::90dd8c6… | hooks/error-spike-detector.sh |
|
||||
| extract-task-durations | shell | hook::shell::6b3a57f… | hooks/extract-task-durations.sh |
|
||||
| graph-export-watcher | shell | hook::shell::8d87d02… | hooks/graph-export-watcher.sh |
|
||||
| milestone-commit-hook | shell | hook::shell::18347ff… | hooks/milestone-commit-hook.sh |
|
||||
| no-downgrade | shell | hook::shell::db31e58… | hooks/no-downgrade.sh |
|
||||
| no-hand-edit-agents | shell | hook::shell::ed728f1… | hooks/no-hand-edit-agents.sh |
|
||||
|
|
@ -884,6 +886,7 @@ Sorted alphabetically by name.
|
|||
| session-end-dump | shell | hook::shell::7c3e2d9… | hooks/session-end-dump.sh |
|
||||
| site-wysiwyd-check | shell | hook::shell::0683fa8… | hooks/site-wysiwyd-check.sh |
|
||||
| skill-record | shell | hook::shell::954ccee… | hooks/skill-record.sh |
|
||||
| sleep-report-tg | shell | hook::shell::2e5b134… | hooks/sleep-report-tg.sh |
|
||||
| stop-verify | shell | hook::shell::adedcfe… | hooks/stop-verify.sh |
|
||||
| task-timer | shell | hook::shell::dda5e94… | hooks/task-timer.sh |
|
||||
| tomd-preread | shell | hook::shell::8a95b76… | hooks/tomd-preread.sh |
|
||||
|
|
@ -1079,6 +1082,7 @@ Sorted alphabetically by name.
|
|||
- `kei-git-gitea` — 2 versions: ea30f0cc → 0de210a2
|
||||
- `kei-git-gitlab` — 2 versions: 744859c4 → 59a5271b
|
||||
- `kei-graph-check` — 2 versions: e08f240e → 2c0e38d8
|
||||
- `kei-graph-export::kei-graph-export` — 26 versions: 2e9d962a → b0f840b1 → 4a42d5f4 → a9d35468 → 1f0c066f → 6f5cd1a9 → 89ae1693 → fbebe21d → 63b761f6 → 643d3f08 → 7ba05286 → ca606a00 → c1f97c41 → 237d050b → 094ddc72 → 006b0f7d → c3d7c243 → a67fc02f → 33beda01 → 615a6cfb → 6dbfd254 → bb6ca1bb → 48cb9c62 → 5529822c → 1b597838 → f17c1aeb
|
||||
- `kei-hibernate` — 2 versions: 25f6d5bc → 1ea136f5
|
||||
- `kei-import-project` — 2 versions: aa3750a0 → 2de0fd64
|
||||
- `kei-leak-matrix` — 2 versions: 06a89af2 → a3803ef9
|
||||
|
|
@ -1146,9 +1150,11 @@ Sorted alphabetically by name.
|
|||
- `no-python-without-approval` — 2 versions: 45d3e0ab → 48fdb89e
|
||||
- `numeric-claims-guard` — 2 versions: 90f697e6 → d5ed33c8
|
||||
- `numeric-claims-record` — 2 versions: 59a9990f → 342361a3
|
||||
- `phase-b-rem` — 4 versions: 69fdc9bc → df6af06f → 223c0c99 → 8545aba8
|
||||
- `post-write-check` — 2 versions: 6ceb2237 → 4aaf1c5e
|
||||
- `safety-guard` — 2 versions: 32b889cf → 665e7cd1
|
||||
- `site-wysiwyd-check` — 2 versions: a0d38a22 → 416c0648
|
||||
- `sleep-report-tg` — 3 versions: acc3ebfb → ef101ab6 → 9529ec50
|
||||
- `ssh-check` — 2 versions: f419e2b0 → ebd97541
|
||||
- `task-timer` — 2 versions: 202823f9 → 16e4f0a3
|
||||
- `tokens-sync` — 2 versions: 54c149ab → 69857925
|
||||
|
|
|
|||
18
hooks/graph-export-watcher.sh
Executable file
18
hooks/graph-export-watcher.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#!/bin/sh
|
||||
# graph-export-watcher.sh — polls kei-graph-export and writes data-runtime.js atomically.
|
||||
# Bypass: GRAPH_EXPORT_BYPASS=1
|
||||
|
||||
INTERVAL="${KEI_GRAPH_EXPORT_INTERVAL_S:-5}"
|
||||
OUT="${KEI_GRAPH_VIZ_DIR:-$HOME/Projects/lbm-graph-viz}/data-runtime.js"
|
||||
BIN="$(command -v kei-graph-export 2>/dev/null || echo "$HOME/.cargo/bin/kei-graph-export")"
|
||||
|
||||
[ -x "$BIN" ] || exit 0
|
||||
[ "${GRAPH_EXPORT_BYPASS:-0}" = "1" ] && exit 0
|
||||
|
||||
mkdir -p "$(dirname "$OUT")" 2>/dev/null
|
||||
|
||||
while true; do
|
||||
"$BIN" --format spaces-fragment --output "$OUT.tmp" 2>/dev/null \
|
||||
&& mv "$OUT.tmp" "$OUT" 2>/dev/null
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
Loading…
Reference in a new issue