feat(skills): rebuild the skill system for the multi-user model
Nightly Build / build (push) Successful in 8m6s
Nightly Build / build (push) Successful in 8m6s
Per blueprint/skill-project.md: the old single-namespace, hand-maintained
index is gone, replaced by a read-only, two-scope tree whose index is a
runtime function of its content.
- skills/ index generated at runtime (crates/skald-core/src/skills/:
inventory, install, validate, watch), injected through the new
<!-- SKILLS_LIST --> placeholder in AGENT.md (agents/common/skills.md);
meta.json inject_skills flag removed. 11 chat/task agents carry the
include, the 4 system agents do not.
- Two trees, both read-only in both directions: skills/shared/{id} (the
group's) and skills/{username}/{id} (one member's own, on the stable
userid). The root is closed too: UserFs::SkillMounts + RouteError (alias
probe, plain-denied paths, no home fallback) and a per-user
.skills-root/{userid} container mount with the two scope mounts nested
inside, plus the fifth self-heal axis (skills_mounted).
- Agent verbs: skill_register/skill_delete (Config group, global scope
behind the new skill.manage capability), fetch_repo for public repos,
list_items(type="skills"); reads are plain read_file on the printed
path. Seeded @fs_read skills/* allow.
- Freshness: a digest-gated watcher on the two trees emits
SystemEvent::SkillsChanged, whose subscriber rebuilds the frozen prompt
prefix via Skald::invalidate_prompt_prefix; in-process writers invalidate
directly.
- The build ships no skills: the three bundled skills (ics2json,
mcp-builder, skill-creator) and skills/index.md are removed, skills/ is
instance data (gitignored, not packaged, no longer pruned by update.sh).
- Docs: skills.md, agents.md, shared-folders.md added; docs/index.md and
agents/README.md updated.
This commit is contained in:
@@ -82,6 +82,14 @@ pub(super) fn spawn_background(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Skills freshness for edits made by hand on the box (blueprint §8.2). The
|
||||
// in-process writers invalidate directly; this watches the two trees and
|
||||
// announces `SkillsChanged` only when the digest gate says the index moved.
|
||||
rt.supervisor.adopt_one(
|
||||
"skills-watch",
|
||||
crate::skills::watch::spawn(Arc::clone(&rt.system_bus), rt.shutdown_token.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Spawns the **user-lifecycle reconciler** — the single subscriber that turns
|
||||
@@ -174,6 +182,48 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns the **skills-freshness reactor** — the subscriber that turns a
|
||||
/// `SkillsChanged` announcement into a prompt-prefix invalidation (blueprint
|
||||
/// §8.3).
|
||||
///
|
||||
/// Why the bus at all, when the skill tools call `invalidate_prompt_prefix`
|
||||
/// directly: the watcher exists for a writer *outside* the process (a hand
|
||||
/// edit on the box), and its consumers live in different places — the per-user
|
||||
/// loop runtimes here, a UI refresh later. A direct call would make the
|
||||
/// watcher hold `Skald`, which it deliberately does not. Best-effort by
|
||||
/// contract, and honestly so: a lost event costs a stale skill index for the
|
||||
/// twenty minutes of the prefix TTL, never a wrong answer.
|
||||
pub(super) fn spawn_skills_freshness(skald: &Arc<super::Skald>) {
|
||||
let weak = Arc::downgrade(skald);
|
||||
let shutdown = skald.rt.shutdown_token.clone();
|
||||
let mut rx = skald.rt.system_bus.subscribe();
|
||||
|
||||
skald.rt.supervisor.spawn("skills-freshness", async move {
|
||||
loop {
|
||||
let event = tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
event = rx.recv() => match event {
|
||||
Ok(e) => e,
|
||||
Err(RecvError::Lagged(n)) => {
|
||||
warn!(n, "skills-freshness: system_bus lagged; a skill index may be stale until the prefix TTL");
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => break,
|
||||
},
|
||||
};
|
||||
|
||||
let SystemEvent::SkillsChanged { scope } = event else { continue };
|
||||
let Some(skald) = weak.upgrade() else { break };
|
||||
let scope = match scope {
|
||||
core_api::system_bus::SkillScope::Global => crate::skills::PromptScope::Everyone,
|
||||
core_api::system_bus::SkillScope::User(id) => crate::skills::PromptScope::User(id),
|
||||
};
|
||||
skald.invalidate_prompt_prefix(scope).await;
|
||||
}
|
||||
info!("skills-freshness: reactor stopped");
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns the **system-agent scheduler** — the one instance-wide timer behind
|
||||
/// every background agent nobody asked for (event triage, the two memory lints).
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user