KeiSeiKit-1.0/_assembler/src/assembler.rs
KeiSei84 e4980f6ad7
Some checks are pending
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / preflight (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / vps-smoke (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:frustration-matrix,kei-frustration-loop,kei-skill-importer,kei-projects-index,kei-projects-watcher,kei-gdrive-import,kei-leak-matrix,kei-skills,kei-gateway,kei-cron-scheduler,kei-export-trajectories,kei-backend-daytona,kei-d… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-compute-baremetal,kei-compute-vultr,kei-compute-linode,kei-compute-digitalocean,kei-svc-systemd,kei-llm-bridge-mlx name:hosted-sleep-compute]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-diff,kei-scheduler,kei-watch,kei-prune,kei-discover,kei-brain-view,kei-hibernate,kei-ledger-sign,kei-fork name:wave13-15]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-git-gitea,kei-git-forgejo,kei-git-gitlab,kei-git-bitbucket,kei-memory-sled,kei-memory-redis,kei-memory-postgres,kei-memory-sqlite,kei-auth-google,kei-auth-apple,kei-auth-magiclink,kei-auth-webauthn,kei-notify-slack,kei-n… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-ledger,kei-migrate,kei-changelog,kei-memory,kei-store,kei-conflict-scan,kei-refactor-engine,kei-graph-check,kei-shared,kei-dna-index,kei-pet name:core]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-machine-probe,kei-llm-ollama,kei-llm-llamacpp,kei-llm-mlx,kei-llm-router,kei-model name:llm-stack]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-router,kei-sage,kei-task,kei-chat-store,kei-crossdomain,kei-search-core,kei-content-store,kei-social-store,kei-curator,kei-auth,kei-artifact name:mcp-lbm]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:keisei,kei-forge,kei-runtime,kei-runtime-core,kei-atom-discovery,kei-agent-runtime,kei-capability,kei-provision,kei-entity-store,kei-pipe,kei-cache,kei-spawn,kei-replay name:atom-substrate]) (push) Blocked by required conditions
feat(dna): provider+model in agent DNA; kei primary; smoke-tested 4/5 CLIs
Makes KeiSeiKit truly multi-LLM: any agent can declare its preferred backend
in its manifest. The DNA resolver picks the right CLI; `kei primary` swaps the
fleet-wide default. KeiSeiKit is no longer tied to Claude Code single-model.

Resolution order: --on=<backend>  →  manifest provider  →  primary.toml  →  claude

Files:
  _assembler/src/manifest.rs   + Option<String> provider field
  _assembler/src/assembler.rs  emit provider: in frontmatter (when set)
  scripts/kei-agent-cli.sh     DNA resolver; `kei primary` get/set; `kei agent`
                               arm (DNA-driven); honest kimi handling (TUI-only)
  bin/kei                      new arms: agent, primary
  _primitives/cli-backends.toml mark kimi as tui-only
  docs/encyclopedia/multi-cli-agents.md  rewritten with DNA flow, smoke
                               results, rule-enforcement caveat

Smoke 2026-05-26 (real CLI invocations):
  claude   ✓ via `claude -p`
  grok     ✓ via `grok --print`            (DNA: manifest provider=grok)
  agy      ✓ via `agy --print`             (Antigravity / Gemini)
  copilot  ✓ via `copilot --prompt`        (1 Premium / 9s / 20.6k tok)
  kimi     ⚠ TUI-only, no print mode; need `kimi acp` JSON-RPC client
  codex    — register-only (not installed locally)

Rule-enforcement caveat documented: KeiSeiKit hooks fire only inside Claude
Code's PreToolUse pipeline. Non-claude backends carry the agent's PROMPT but
not the hook layer. For tool-level policy on non-claude, route through MCP.

ALSO: fix(stop-hook) — RULE 0.14 session-end-dump.sh "Recombobulating..."
4-minute hang on 18MB+ transcripts. Root cause: kei-memory ingest + frustration-
matrix scan + kei-sleep-sync ran sync at session end. Now async-detached with
per-op portable timeout (timeout/gtimeout/perl alarm). Hook returns in 0.03s.
Raw JSONL saved sync; only index/embedding step deferred (idempotent on
session_id so safe).
2026-05-26 16:21:11 +08:00

246 lines
8.3 KiB
Rust

//! Agent assembler — composes markdown from manifest + blocks.
//! Output is deterministic: same manifest + blocks → byte-identical .md.
use crate::manifest::Manifest;
use crate::registry_client;
use crate::substrate;
use std::fs;
use std::path::Path;
pub fn assemble(m: &Manifest, blocks_dir: &Path) -> Result<String, String> {
// Substrate role expansion uses the kit root (parent of _blocks/).
let root = blocks_dir
.parent()
.ok_or_else(|| "blocks_dir has no parent (can't locate _roles/ and _capabilities/)".to_string())?;
let mut out = String::new();
write_frontmatter(m, &mut out);
write_role(m, &mut out);
write_substrate(m, root, &mut out)?;
write_rule_blocks(m, &mut out)?;
write_blocks(m, blocks_dir, &mut out)?;
write_domain_scope(m, &mut out);
write_handoffs(m, &mut out);
write_output_format(m, &mut out);
write_forbidden(m, &mut out);
write_references(m, &mut out);
Ok(out)
}
fn write_substrate(m: &Manifest, root: &Path, out: &mut String) -> Result<(), String> {
let Some(role) = &m.substrate_role else {
return Ok(());
};
let section = substrate::build_substrate_section(root, role)?;
out.push_str(&section);
Ok(())
}
fn write_frontmatter(m: &Manifest, out: &mut String) {
let desc = m.description.replace('\n', " ");
out.push_str("---\n");
out.push_str(&format!("name: {}\n", m.name));
out.push_str(&format!("description: {}\n", desc.trim()));
out.push_str(&format!("tools: {}\n", m.tools.join(", ")));
out.push_str(&format!("model: {}\n", m.model));
// v0.39: optional provider for DNA-resolved kei agent dispatch.
if let Some(prov) = &m.provider {
if !prov.is_empty() {
out.push_str(&format!("provider: {}\n", prov));
}
}
out.push_str("---\n\n");
out.push_str(&format!(
"<!-- GENERATED by _assembler (Rust) from _manifests/{}.toml — DO NOT EDIT. Edit the manifest. -->\n\n",
m.name
));
}
fn write_role(m: &Manifest, out: &mut String) {
out.push_str("# ROLE\n\n");
out.push_str(m.role.trim());
out.push_str("\n\n");
}
fn write_blocks(m: &Manifest, blocks_dir: &Path, out: &mut String) -> Result<(), String> {
for block in &m.blocks {
let path = blocks_dir.join(format!("{block}.md"));
let text = fs::read_to_string(&path)
.map_err(|e| format!("read {}: {e}", path.display()))?;
out.push_str(text.trim());
out.push_str("\n\n");
}
Ok(())
}
fn write_rule_blocks(m: &Manifest, out: &mut String) -> Result<(), String> {
if m.rule_blocks.is_empty() {
return Ok(());
}
let db_path = registry_client::default_db_path();
if !db_path.exists() {
eprintln!("warn [assembler]: registry not found at {} — skipping rule_blocks", db_path.display());
return Ok(());
}
let conn = registry_client::open_read_only(&db_path)?;
for name in &m.rule_blocks {
match registry_client::find_rule(&conn, name) {
Ok(Some(body)) => {
out.push_str(&format!("<!-- RULE: {name} -->\n"));
out.push_str(body.trim());
out.push_str("\n<!-- /RULE -->\n\n");
}
Ok(None) => {
return Err(format!(
"rule_block '{name}' not found in registry — \
run `kei-decompose decompose-rules` first or remove from manifest"
));
}
Err(e) => return Err(format!("registry lookup for '{name}': {e}")),
}
}
Ok(())
}
fn write_domain_scope(m: &Manifest, out: &mut String) {
out.push_str("# DOMAIN SCOPE\n\n**In:**\n");
for item in &m.domain_in {
out.push_str(&format!("- {item}\n"));
}
out.push_str("\n**Out (hand off):**\n");
for h in &m.handoff {
out.push_str(&format!("- `{}` — {}\n", h.target, h.trigger));
}
out.push('\n');
}
fn write_handoffs(m: &Manifest, out: &mut String) {
out.push_str("# HANDOFFS\n\n");
for h in &m.handoff {
out.push_str(&format!("- **{}** — {}\n", h.target, h.trigger));
}
out.push('\n');
}
fn write_output_format(m: &Manifest, out: &mut String) {
out.push_str("# OUTPUT FORMAT\n\n```\n");
out.push_str(&format!("=== {} REPORT ===\n", m.name.to_uppercase()));
out.push_str("Goal: <one-line>\n");
out.push_str("Scope: <in / out>\n");
out.push_str("Plan: <N steps>\n");
out.push_str("Executed: <files touched, LOC delta>\n");
out.push_str("Verify: <each criterion pass/fail>\n");
out.push_str("Evidence grades: <E1-E6 for each major claim>\n");
out.push_str("Handoffs made: <list>\n");
for extra in &m.output_extra_fields {
out.push_str(extra);
out.push('\n');
}
out.push_str("Blockers / next: <list>\n");
out.push_str("```\n\n");
}
fn write_forbidden(m: &Manifest, out: &mut String) {
out.push_str("# FORBIDDEN\n\n");
for item in &m.forbidden_domain {
out.push_str(&format!("- {item}\n"));
}
out.push('\n');
}
fn write_references(m: &Manifest, out: &mut String) {
out.push_str("# REFERENCES\n\n");
out.push_str("- `~/.claude/CLAUDE.md` — baseline umbrella\n");
out.push_str("- `~/.claude/memory/MEMORY.md` — memory index (adjust if your Claude Code user-slug path differs)\n");
if let Some(mp) = &m.memory_project {
out.push_str(&format!(
"- `~/.claude/memory/{mp}` — project memory (adjust path if needed)\n"
));
}
if let Some(pc) = &m.project_claudemd {
out.push_str(&format!("- `{pc}` — project CLAUDE.md\n"));
}
if let Some(refs) = &m.references {
// Open registry for path-atom resolution. Missing DB is non-fatal —
// references then fall through unchanged (advisory only).
let conn = {
let db_path = registry_client::default_db_path();
if db_path.exists() {
registry_client::open_read_only(&db_path).ok()
} else {
None
}
};
for r in &refs.extra {
let resolved = resolve_path_atom_ref(r, conn.as_ref());
out.push_str(&format!("- `{resolved}`\n"));
}
}
}
/// Resolve a `path:NAME/file.md` reference to an opaque content-addressed
/// form `{path::NAME}/file.md` if `NAME` is a registered path-atom.
///
/// Behaviour:
/// - Input does not start with `path:` → return unchanged.
/// - Input starts with `path:` but lookup fails (no atom / not a path-atom /
/// no registry) → emit a stderr warning and return the input unchanged.
/// This is advisory: the warn surfaces typos in manifests but never
/// blocks rendering.
/// - Lookup succeeds → return `{path::NAME}/<suffix>`.
fn resolve_path_atom_ref(r: &str, conn: Option<&rusqlite::Connection>) -> String {
let Some(rest) = r.strip_prefix("path:") else {
return r.to_string();
};
let (name, suffix) = match rest.split_once('/') {
Some((n, s)) => (n, s),
// `path:NAME` with no `/file` part — atom-only ref. Resolve to
// `{path::NAME}` so the public output is still opaque.
None => (rest, ""),
};
let Some(c) = conn else {
eprintln!(
"warn [assembler]: 'path:{name}' reference but registry DB not open — passing through"
);
return r.to_string();
};
match registry_client::is_path_atom(c, name) {
Ok(true) => {
if suffix.is_empty() {
format!("{{path::{name}}}")
} else {
format!("{{path::{name}}}/{suffix}")
}
}
Ok(false) => {
eprintln!(
"warn [assembler]: 'path:{name}' not found in registry as path-atom — passing through"
);
r.to_string()
}
Err(e) => {
eprintln!("warn [assembler]: registry lookup for path-atom '{name}': {e}");
r.to_string()
}
}
}
#[cfg(test)]
mod write_references_tests {
use super::resolve_path_atom_ref;
#[test]
fn passthrough_non_path_ref() {
let r = "~/.claude/memory/foo.md";
assert_eq!(resolve_path_atom_ref(r, None), r);
}
#[test]
fn passthrough_path_ref_when_no_db() {
// No registry conn → emit warn (suppressed in test) + passthrough.
let r = "path:user-memory/foo.md";
assert_eq!(resolve_path_atom_ref(r, None), r);
}
}