Author runnable skill bodies via OpenAI-compatible codegen model
create_skill now writes a real predict/act body: the small decision model still authors title + description (engine.generate), then a larger OpenAI-compatible model (default qwen38-iq3s) writes the runnable code against the SKILL.md contract. Bodies persist to data/skills/<cat>/<name>.py, are hot-loaded via importlib, merged into the running tree, and the request re-dispatches to the new leaf. The dashboard decision-flow view shows the title/description with a writing badge while the body is being written. Codegen failure degrades to a navigable stub.
This commit is contained in:
@@ -18,6 +18,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .dream import dream as run_dream
|
||||
from .codegen import CodegenClient
|
||||
from .engine import EngineConfig, EngineUnavailable, SemIfEngine
|
||||
from .llm import LLMClient
|
||||
from .log import DecisionLog
|
||||
@@ -45,6 +46,14 @@ def build_scheduler(config: dict) -> tuple[Scheduler, dict]:
|
||||
base_url=config.get("llm", {}).get("base_url", "http://localhost:11434/v1"),
|
||||
model=config.get("llm", {}).get("model", "qwen2.5:3b"),
|
||||
)
|
||||
codegen_cfg = config.get("codegen", {})
|
||||
codegen = CodegenClient(
|
||||
base_url=codegen_cfg.get(
|
||||
"base_url", config.get("llm", {}).get("base_url", "http://localhost:11434/v1")
|
||||
),
|
||||
model=codegen_cfg.get("model", "qwen38-iq3s"),
|
||||
timeout=float(codegen_cfg.get("timeout", 600.0)),
|
||||
)
|
||||
log = DecisionLog(config.get("log", "data/decisions.jsonl"))
|
||||
trace = TraceLog(config.get("trace", "data/runs.jsonl"))
|
||||
scheduler = Scheduler(
|
||||
@@ -55,6 +64,7 @@ def build_scheduler(config: dict) -> tuple[Scheduler, dict]:
|
||||
tau=float(config.get("tau", 0.6)),
|
||||
max_reentries=int(config.get("max_reentries", 3)),
|
||||
trace=trace,
|
||||
codegen=codegen,
|
||||
)
|
||||
return scheduler, config
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Skill code-body generation through an OpenAI-compatible API.
|
||||
|
||||
The decision engine authors a skill's title + description with the small model
|
||||
(engine.generate). Writing the runnable body is a separate step: a larger
|
||||
OpenAI-compatible model (e.g. qwen38-iq3s on ollama) is prompted with the
|
||||
SKILL.md contract plus the request and the existing tree, and must reply with
|
||||
valid Python implementing `predict` / `act`. Stdlib-only HTTP, mirroring
|
||||
`llm.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from .decisions import Request
|
||||
from .skills import SkillDraft, tree_summary
|
||||
|
||||
DEFAULT_CONTRACT = Path(__file__).resolve().parent.parent / "SKILL.md"
|
||||
|
||||
|
||||
class CodegenError(RuntimeError):
|
||||
"""The codegen endpoint could not be reached."""
|
||||
|
||||
|
||||
def read_skill_contract(path: str | None = None) -> str:
|
||||
contract = Path(path) if path else DEFAULT_CONTRACT
|
||||
if not contract.is_file():
|
||||
raise CodegenError(f"skill contract not found: {contract}")
|
||||
return contract.read_text()
|
||||
|
||||
|
||||
class CodegenClient:
|
||||
"""Minimal OpenAI-compatible chat client for writing skill bodies."""
|
||||
|
||||
def __init__(self, base_url: str, model: str, timeout: float = 600.0):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
|
||||
def chat(self, messages: list[dict], max_tokens: int = 2048, temperature: float = 0.0) -> str:
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url, data=body, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.URLError as exc:
|
||||
raise CodegenError(
|
||||
f"codegen endpoint unreachable at {url}: {exc}. Is your local server running?"
|
||||
) from exc
|
||||
return payload["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
def build_skill_body_prompt(
|
||||
request: Request,
|
||||
category: str,
|
||||
draft: SkillDraft,
|
||||
tree: dict,
|
||||
contract: str,
|
||||
) -> list[dict]:
|
||||
"""Messages for the code-generation model.
|
||||
|
||||
The small model already chose the title + description; the big model only
|
||||
writes the runnable body against the SKILL.md contract, informed by the
|
||||
request, the category, and the existing skills so it avoids duplication.
|
||||
"""
|
||||
existing = ", ".join(s.name for s in tree.get(category, [])) or "(none)"
|
||||
system = (
|
||||
"You write runnable skill bodies for a local agent. The contract below "
|
||||
"is authoritative: follow it exactly.\n\n"
|
||||
f"{contract}"
|
||||
)
|
||||
user = (
|
||||
f"Request: {request.text}\n"
|
||||
f"Category: {category}\n"
|
||||
f"Skill name: {draft.name}\n"
|
||||
f"Skill description: {draft.description}\n"
|
||||
f"Existing skills in this category: {existing}\n"
|
||||
f"Existing categories:\n{tree_summary(tree)}\n"
|
||||
"Write the Python module body now. Reply with ONLY valid Python code "
|
||||
"defining `predict` and `act`. No prose, no markdown fences, no JSON."
|
||||
)
|
||||
return [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
]
|
||||
|
||||
|
||||
def parse_skill_body(raw: str) -> str:
|
||||
"""Extract and validate a Python skill body from the model's reply.
|
||||
|
||||
Accepts bare code, ```fenced``` code, or JSON {"code": "..."}. The body
|
||||
must parse and must define module-level `predict` and `act` functions.
|
||||
Returns the cleaned source. Raises ValueError otherwise.
|
||||
"""
|
||||
text = raw.strip()
|
||||
if "code" in text[:400] and "{" in text and "}" in text:
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
try:
|
||||
parsed = json.loads(text[start : end + 1])
|
||||
candidate = parsed.get("code")
|
||||
if isinstance(candidate, str):
|
||||
text = candidate.strip()
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
for fence in ("```python", "```py", "```"):
|
||||
start = text.find(fence)
|
||||
if start != -1:
|
||||
text = text[start + len(fence):]
|
||||
close = text.rfind("```")
|
||||
if close != -1:
|
||||
text = text[:close]
|
||||
break
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("skill body is empty")
|
||||
try:
|
||||
module = ast.parse(text, filename="<generated>")
|
||||
except SyntaxError as exc:
|
||||
raise ValueError(f"skill body is not valid Python: {exc}") from exc
|
||||
names = {node.name for node in module.body if isinstance(node, ast.FunctionDef)}
|
||||
for required in ("predict", "act"):
|
||||
if required not in names:
|
||||
raise ValueError(f"skill body must define a module-level `{required}` function")
|
||||
return text
|
||||
|
||||
|
||||
def generate_skill_body(
|
||||
client: CodegenClient,
|
||||
request: Request,
|
||||
category: str,
|
||||
draft: SkillDraft,
|
||||
tree: dict,
|
||||
contract: str | None = None,
|
||||
max_tokens: int = 2048,
|
||||
) -> str:
|
||||
"""Author a skill body with the big model; retries once on invalid output."""
|
||||
contract_text = contract if contract is not None else read_skill_contract()
|
||||
messages = build_skill_body_prompt(request, category, draft, tree, contract_text)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
raw = client.chat(messages, max_tokens=max_tokens)
|
||||
return parse_skill_body(raw)
|
||||
except ValueError as exc:
|
||||
last_error = exc
|
||||
messages = messages + [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"That was rejected: {exc}. Reply with ONLY the Python code "
|
||||
"now — no prose, no fences, no JSON."
|
||||
),
|
||||
}
|
||||
]
|
||||
raise ValueError(f"skill body rejected twice: {last_error}")
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .codegen import CodegenClient, CodegenError, generate_skill_body
|
||||
from .decisions import DecisionRequest, Option, Request
|
||||
from .engine import SemIfEngine
|
||||
from .llm import LLMClient
|
||||
@@ -21,12 +22,15 @@ from .skills import (
|
||||
CreateCategory,
|
||||
CreateSkill,
|
||||
Skill,
|
||||
SkillBodyStore,
|
||||
build_skills,
|
||||
build_tree,
|
||||
compose_state,
|
||||
generate_category,
|
||||
generate_skill,
|
||||
materialize_skill,
|
||||
merge_registry,
|
||||
merge_skill_bodies,
|
||||
navigate,
|
||||
)
|
||||
from .trace import TraceLog
|
||||
@@ -55,6 +59,7 @@ class DispatchResult:
|
||||
summary: str
|
||||
skill: str | None = None
|
||||
decisions_logged: int = 0
|
||||
body_written: bool = False
|
||||
|
||||
|
||||
class Scheduler:
|
||||
@@ -67,6 +72,7 @@ class Scheduler:
|
||||
tau: float = 0.6,
|
||||
max_reentries: int = 3,
|
||||
trace: TraceLog | None = None,
|
||||
codegen: CodegenClient | None = None,
|
||||
):
|
||||
self.engine = engine
|
||||
self.llm = llm
|
||||
@@ -75,6 +81,7 @@ class Scheduler:
|
||||
self.config = config
|
||||
self.tau = tau
|
||||
self.max_reentries = max_reentries
|
||||
self.codegen = codegen
|
||||
self.queue = UrgencyQueue(
|
||||
max_size=int(config.get("queue", {}).get("max_size", 100)),
|
||||
age_rate=float(config.get("queue", {}).get("age_rate", 0.0)),
|
||||
@@ -82,7 +89,9 @@ class Scheduler:
|
||||
self.skills = build_skills(config)
|
||||
self.tree = build_tree(self.skills)
|
||||
self.registry = CategoryRegistry(config.get("category_registry", "data/categories.json"))
|
||||
self.body_store = SkillBodyStore(config.get("skill_bodies", "data/skills"))
|
||||
merge_registry(self.tree, self.registry.read())
|
||||
merge_skill_bodies(self.tree, self.body_store, self.registry.read())
|
||||
self.ctx = ActionContext(engine=self.engine, config=config)
|
||||
self.runner = SkillRunner(self.ctx, self.llm, self.log)
|
||||
self.current: Process | None = None
|
||||
@@ -210,22 +219,32 @@ class Scheduler:
|
||||
|
||||
# ---- dispatch ----
|
||||
|
||||
def _dispatch(self, request: Request) -> DispatchResult:
|
||||
def _dispatch(self, request: Request, _depth: int = 0) -> DispatchResult:
|
||||
navigation = navigate(self.engine, self.log, self.trace, request, self.tree)
|
||||
if isinstance(navigation, CreateCategory):
|
||||
return self._create_category(request)
|
||||
if isinstance(navigation, CreateSkill):
|
||||
return self._create_skill(request, navigation.category)
|
||||
outcome = self.runner.run(navigation, request)
|
||||
created = self._create_skill(request, navigation.category)
|
||||
if created.kind == "create_skill" and created.body_written and _depth < self.max_reentries:
|
||||
requeued = _requeue(request, request.text)
|
||||
self.trace.append(
|
||||
"requeued", request.id, text=request.text, reason="skill created"
|
||||
)
|
||||
return self._dispatch(requeued, _depth=_depth + 1)
|
||||
return created
|
||||
return self._run_skill(navigation, request)
|
||||
|
||||
def _run_skill(self, skill: Skill, request: Request) -> DispatchResult:
|
||||
outcome = self.runner.run(skill, request)
|
||||
if outcome.error:
|
||||
self.trace.append(
|
||||
"error", request.id, skill=navigation.name, message=outcome.error
|
||||
"error", request.id, skill=skill.name, message=outcome.error
|
||||
)
|
||||
return DispatchResult(kind="error", summary=f"skill error: {outcome.error}")
|
||||
self.trace.append(
|
||||
"assessed",
|
||||
request.id,
|
||||
skill=navigation.name,
|
||||
skill=skill.name,
|
||||
success=outcome.success,
|
||||
summary=outcome.summary,
|
||||
updated_request=outcome.updated_request,
|
||||
@@ -235,8 +254,8 @@ class Scheduler:
|
||||
self.trace.append("requeued", request.id, text=outcome.updated_request)
|
||||
return DispatchResult(
|
||||
kind="ran",
|
||||
summary=f"{navigation.name}: {'ok' if outcome.success else 'failed'} — {outcome.summary}",
|
||||
skill=navigation.name,
|
||||
summary=f"{skill.name}: {'ok' if outcome.success else 'failed'} — {outcome.summary}",
|
||||
skill=skill.name,
|
||||
decisions_logged=outcome.decisions_logged,
|
||||
)
|
||||
|
||||
@@ -275,7 +294,14 @@ class Scheduler:
|
||||
)
|
||||
|
||||
def _create_skill(self, request: Request, category: str) -> DispatchResult:
|
||||
"""Author a new skill leaf stub with the decision model in generation mode."""
|
||||
"""Author a new skill leaf with the decision model in generation mode.
|
||||
|
||||
The small model writes the title + description; a larger OpenAI-
|
||||
compatible model then writes the runnable body against SKILL.md. The
|
||||
stub is registered first so the leaf is navigable even if the body
|
||||
write fails; a successful write is merged into the tree as a runnable
|
||||
skill and the request re-dispatches to it.
|
||||
"""
|
||||
from .engine import EngineUnavailable
|
||||
|
||||
try:
|
||||
@@ -296,21 +322,78 @@ class Scheduler:
|
||||
kind="error",
|
||||
summary=f"create_skill failed: {draft.name} already exists",
|
||||
)
|
||||
|
||||
self.registry.register_skill(category, draft.name, draft.description)
|
||||
self.tree.setdefault(category, []).append(
|
||||
Skill(name=draft.name, category=category, description=draft.description)
|
||||
)
|
||||
self.trace.append(
|
||||
"skill_writing",
|
||||
request.id,
|
||||
category=category,
|
||||
skill=draft.name,
|
||||
description=draft.description,
|
||||
model=self.codegen.model if self.codegen else None,
|
||||
)
|
||||
|
||||
if self.codegen is None:
|
||||
self.trace.append(
|
||||
"skill_created",
|
||||
request.id,
|
||||
category=category,
|
||||
skill=draft.name,
|
||||
description=draft.description,
|
||||
body=None,
|
||||
written=False,
|
||||
)
|
||||
return DispatchResult(
|
||||
kind="create_skill",
|
||||
summary=f"created stub {category}.{draft.name}: {draft.description} (no codegen configured)",
|
||||
skill=draft.name,
|
||||
)
|
||||
|
||||
try:
|
||||
draft.code = generate_skill_body(
|
||||
self.codegen, request, category, draft, self.tree
|
||||
)
|
||||
skill = materialize_skill(draft, category, self.body_store)
|
||||
except (CodegenError, ValueError) as exc:
|
||||
self.trace.append(
|
||||
"error",
|
||||
request.id,
|
||||
phase="create_skill",
|
||||
category=category,
|
||||
message=f"skill body write failed: {exc}",
|
||||
)
|
||||
return DispatchResult(
|
||||
kind="create_skill",
|
||||
summary=f"created stub {category}.{draft.name}: {draft.description} (body write failed: {exc})",
|
||||
skill=draft.name,
|
||||
)
|
||||
|
||||
skills = self.tree.setdefault(category, [])
|
||||
for index, existing in enumerate(skills):
|
||||
if existing.name == draft.name:
|
||||
skills[index] = skill
|
||||
break
|
||||
else:
|
||||
skills.append(skill)
|
||||
skills.sort(key=lambda s: s.name)
|
||||
body_path = self.body_store.body_path(category, draft.name).as_posix()
|
||||
self.trace.append(
|
||||
"skill_created",
|
||||
request.id,
|
||||
category=category,
|
||||
skill=draft.name,
|
||||
description=draft.description,
|
||||
body=body_path,
|
||||
written=True,
|
||||
)
|
||||
return DispatchResult(
|
||||
kind="create_skill",
|
||||
summary=f"created skill {category}.{draft.name}: {draft.description}",
|
||||
skill=draft.name,
|
||||
body_written=True,
|
||||
)
|
||||
|
||||
def status(self) -> str:
|
||||
|
||||
@@ -12,6 +12,7 @@ Only the real skills live here; navigation uses the real decision engine.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -95,6 +96,7 @@ class SkillDraft:
|
||||
|
||||
name: str
|
||||
description: str
|
||||
code: str = ""
|
||||
|
||||
|
||||
class CategoryRegistry:
|
||||
@@ -130,6 +132,113 @@ class CategoryRegistry:
|
||||
self.path.write_text(json.dumps(categories, indent=2) + "\n")
|
||||
|
||||
|
||||
class SkillBodyStore:
|
||||
"""Persists runnable skill bodies as one Python file per skill.
|
||||
|
||||
Layout: <base>/<category>/<name>.py. Bodies are written by the codegen step
|
||||
and loaded back at startup so skills stay runnable across restarts.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str = "data/skills"):
|
||||
self.path = Path(path)
|
||||
|
||||
def write(self, category: str, name: str, code: str) -> Path:
|
||||
directory = self.path / category
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
target = directory / f"{name}.py"
|
||||
target.write_text(code.rstrip() + "\n")
|
||||
return target
|
||||
|
||||
def body_path(self, category: str, name: str) -> Path:
|
||||
return self.path / category / f"{name}.py"
|
||||
|
||||
def list_bodies(self) -> list[tuple[str, str]]:
|
||||
if not self.path.is_dir():
|
||||
return []
|
||||
bodies = []
|
||||
for directory in sorted(self.path.iterdir()):
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
for module in sorted(directory.glob("*.py")):
|
||||
bodies.append((directory.name, module.stem))
|
||||
return bodies
|
||||
|
||||
|
||||
def load_skill_module(category: str, name: str, base: str = "data/skills"):
|
||||
"""Import a persisted skill body and return its module."""
|
||||
path = Path(base) / category / f"{name}.py"
|
||||
module_name = f"_skill_{category}_{name}".replace("-", "_")
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ValueError(f"cannot load skill module: {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def materialize_skill(
|
||||
draft: SkillDraft, category: str, store: SkillBodyStore
|
||||
) -> Skill:
|
||||
"""Persist the draft's code body and build a runnable Skill from it."""
|
||||
if not draft.code:
|
||||
raise ValueError(f"skill {draft.name} has no code body to materialize")
|
||||
store.write(category, draft.name, draft.code)
|
||||
try:
|
||||
module = load_skill_module(category, draft.name, store.path)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"skill {category}.{draft.name} body failed to import: {exc}") from exc
|
||||
if not callable(getattr(module, "predict", None)) or not callable(
|
||||
getattr(module, "act", None)
|
||||
):
|
||||
raise ValueError(f"skill {category}.{draft.name} body must define predict and act")
|
||||
return Skill(
|
||||
name=draft.name,
|
||||
category=category,
|
||||
description=draft.description,
|
||||
predict=module.predict,
|
||||
act=module.act,
|
||||
)
|
||||
|
||||
|
||||
def merge_skill_bodies(
|
||||
tree: dict[str, list[Skill]], store: SkillBodyStore, registry: dict[str, dict]
|
||||
) -> int:
|
||||
"""Upgrade persisted skill bodies in the tree to runnable skills.
|
||||
|
||||
A body file makes a stub leaf executable; where the registry entry was lost
|
||||
(or never written), the category is created and the description falls back
|
||||
to the skill name. Returns the number of skills made runnable.
|
||||
"""
|
||||
upgraded = 0
|
||||
for category, name in store.list_bodies():
|
||||
description = ""
|
||||
entry = registry.get(category, {})
|
||||
for skill in entry.get("skills", []):
|
||||
if skill.get("name") == name:
|
||||
description = skill.get("description", "")
|
||||
try:
|
||||
module = load_skill_module(category, name, store.path)
|
||||
except Exception:
|
||||
continue
|
||||
skill = Skill(
|
||||
name=name,
|
||||
category=category,
|
||||
description=description or name,
|
||||
predict=module.predict,
|
||||
act=module.act,
|
||||
)
|
||||
skills = tree.setdefault(category, [])
|
||||
for index, existing in enumerate(skills):
|
||||
if existing.name == name:
|
||||
skills[index] = skill
|
||||
break
|
||||
else:
|
||||
skills.append(skill)
|
||||
skills.sort(key=lambda s: s.name)
|
||||
upgraded += 1
|
||||
return upgraded
|
||||
|
||||
|
||||
def compose_state(request: Request, current: str | None = None) -> str:
|
||||
parts = [request.text]
|
||||
if current:
|
||||
|
||||
@@ -121,6 +121,11 @@ function eventRow(evt) {
|
||||
div.textContent = `queued (${evt.label})`;
|
||||
} else if (evt.kind === "preempted") {
|
||||
div.textContent = `preempted ${evt.preempted}`;
|
||||
} else if (evt.kind === "skill_writing") {
|
||||
div.textContent = `writing skill ${evt.skill} — ${evt.description} (${evt.model || "codegen"})`;
|
||||
} else if (evt.kind === "skill_created") {
|
||||
div.textContent = `created ${evt.skill}${evt.written ? " (body written)" : " (stub)"}`;
|
||||
div.title = evt.body || evt.description || "";
|
||||
}
|
||||
return div;
|
||||
}
|
||||
@@ -327,6 +332,36 @@ function eventNode(evt) {
|
||||
body.textContent = `interrupted ${evt.preempted}, requeued with state`;
|
||||
} else if (evt.kind === "dropped") {
|
||||
body.textContent = evt.reason || "";
|
||||
} else if (evt.kind === "skill_writing") {
|
||||
node.classList.add("writing");
|
||||
const title = document.createElement("div");
|
||||
title.className = "skill-title";
|
||||
title.textContent = evt.skill;
|
||||
body.appendChild(title);
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "muted";
|
||||
desc.textContent = evt.description || "";
|
||||
body.appendChild(desc);
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "writing-badge";
|
||||
badge.textContent = "writing skill body…";
|
||||
node.appendChild(badge);
|
||||
} else if (evt.kind === "skill_created") {
|
||||
const title = document.createElement("div");
|
||||
title.className = "skill-title";
|
||||
title.textContent = evt.skill;
|
||||
body.appendChild(title);
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "muted";
|
||||
desc.textContent = evt.description || "";
|
||||
body.appendChild(desc);
|
||||
if (evt.body) {
|
||||
const p = document.createElement("div");
|
||||
p.className = "muted";
|
||||
p.textContent = `body: ${evt.body}`;
|
||||
node.appendChild(p);
|
||||
}
|
||||
node.classList.add(evt.written ? "ok" : "stub");
|
||||
} else {
|
||||
body.textContent = evt.summary || evt.text || "";
|
||||
}
|
||||
|
||||
@@ -283,6 +283,26 @@ select {
|
||||
|
||||
.node.event-node .evt-kind { text-transform: uppercase; color: var(--muted); font-size: 10px; }
|
||||
|
||||
.node.event-node .skill-title { font-weight: 700; }
|
||||
.node.event-node.writing { border-left: 3px solid var(--warn); }
|
||||
.node.event-node.stub { border-left: 3px solid var(--muted); }
|
||||
|
||||
.writing-badge {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--warn);
|
||||
border-radius: 10px;
|
||||
color: var(--warn);
|
||||
font-size: 10px;
|
||||
animation: writing-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes writing-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ---------- inspector / tree / status ---------- */
|
||||
|
||||
.right { border-right: none; }
|
||||
|
||||
Reference in New Issue
Block a user