Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5c2d6eb
feat(deck): scaffold openkb/deck/ package with path helpers
KylinMountain May 22, 2026
57684ea
feat(deck): add write_deck_file + read_deck_file tools
KylinMountain May 22, 2026
31ee46f
feat(deck): structural validator with error/warning ValidationResult
KylinMountain May 22, 2026
128706c
fix(deck): flag external <img>, suppress distinct-type warning on emp…
KylinMountain May 22, 2026
1c15254
feat(deck): deck_create system prompt with Editorial Monocle tokens
KylinMountain May 22, 2026
145778a
fix(deck): prompt fixes — folio size, no bitmap images, cover label e…
KylinMountain May 22, 2026
4d2f8c3
feat(deck): deck-create agent (critique=False path)
KylinMountain May 22, 2026
7ded561
feat(deck): critic agent with snapshot/restore safety hooks
KylinMountain May 22, 2026
5a1a226
fix(deck): add cleanup_pre_critique helper, instruct critic to ignore…
KylinMountain May 22, 2026
cf4ec2e
feat(deck): wire critic via SDK handoff when --critique is set
KylinMountain May 22, 2026
714563d
fix(deck): make snapshot a critic tool to close fallback-safety gap
KylinMountain May 22, 2026
ca19c23
docs(deck): update critic.py docstring after lifecycle-hook removal
KylinMountain May 22, 2026
6e16c4a
feat(deck): register deck target_type in Generator dispatch
KylinMountain May 22, 2026
96c8ea0
feat(cli): add openkb deck new subcommand
KylinMountain May 22, 2026
daacce5
feat(deck): /deck new chat slash command
KylinMountain May 22, 2026
710978a
fix(deck): wire cleanup on critique success + CLI wiki preflight parity
KylinMountain May 22, 2026
8c59f0c
docs(deck): correct doc/comment drift surfaced by code review
KylinMountain May 23, 2026
03c3bbb
feat(chat): skill loader — chat can now auto-discover & invoke Anthro…
KylinMountain May 23, 2026
ced612a
fix(deck): v1.1 visual rules in prompt (10px bar, 18ch title, kbd fad…
KylinMountain May 23, 2026
08e95c3
feat(chat,deck): unify on skill runner — deck CLI delegates, chat get…
KylinMountain May 23, 2026
708baa3
refactor(deck): delete legacy pre-skill-system code paths
KylinMountain May 23, 2026
63b62c2
fix(deck): close 6 high-confidence findings from skill-refactor review
KylinMountain May 24, 2026
916b938
fix(deck): code-quality bot findings + macOS /tmp symlink regression
KylinMountain May 24, 2026
abd2923
test(skills): direct unit tests for skill_runner/skills/critique-slas…
KylinMountain May 24, 2026
686a8e4
docs(generator): replace 'future targets plug in' drift with honest s…
KylinMountain May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 191 additions & 1 deletion openkb/agent/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
" /lint Lint the knowledge base\n"
" /add <path> Add a document or directory to the knowledge base\n"
' /skill new <name> "<intent>" Compile a skill from the wiki\n'
' /deck new [--critique] [--skill <name>] <name> "<intent>" Generate an HTML deck from the wiki\n'
" /critique <path-to-html> Run html-critic skill on a file (CSS bugs, layout, self-containment)\n"
" /help Show this"
)

Expand Down Expand Up @@ -216,6 +218,8 @@ def _bottom_toolbar(session: ChatSession) -> FormattedText:
("/lint", "Lint the knowledge base"),
("/add", "Add a document or directory"),
("/skill", "Compile a skill (try `/skill new <name> \"intent\"`)"),
("/deck", "Generate a deck (try `/deck new <name> \"intent\"`)"),
("/critique", "Run html-critic skill on a file (e.g. `/critique output/decks/foo/index.html`)"),
]


Expand Down Expand Up @@ -558,7 +562,9 @@ async def _handle_slash_skill(arg: str, kb_dir: Path, style: Style) -> None:
_fmt(style, ("class:error", f"[ERROR] {exc}\n"))
return

# Surface validation issues from Generator.run (same gate as CLI).
# Surface validation issues from Generator.run. Unlike the CLI
# (which exits 1 on validation errors), chat is interactive — print
# issues inline and continue so the user can inspect and iterate.
result = gen.validation
if result is not None and (result.errors or result.warnings):
_fmt(style, ("class:error", "[WARN] Validation found issues:\n"))
Expand All @@ -576,6 +582,120 @@ async def _handle_slash_skill(arg: str, kb_dir: Path, style: Style) -> None:
f"edit files under output/skills/{name}/ directly.\n"))


async def _handle_slash_deck(arg: str, kb_dir: Path, style: Style) -> None:
"""Dispatch ``/deck new [--critique] <name> "<intent>"``.

Mirrors :func:`_handle_slash_skill`: validates the name, runs the
same wiki preflight gate, refuses to overwrite an existing deck
(chat has no ``-y`` flag), then invokes ``Generator(target_type="deck")``.
"""
import shlex

try:
parts = shlex.split(arg) if arg else []
except ValueError as exc:
_fmt(style, ("class:error", f"[ERROR] Could not parse: {exc}\n"))
return
if not parts:
_fmt(style, ("class:error",
"Usage: /deck new [--critique] <name> \"<intent>\"\n"))
return

sub = parts[0].lower()
if sub != "new":
_fmt(style, ("class:error", f"Unknown deck subcommand: {sub}. Try /deck new.\n"))
return

# Parse optional --critique flag and --skill <name> option. Both can
# appear anywhere among the remaining tokens.
rest = parts[1:]
critique = False
skill_name: str | None = None
filtered: list[str] = []
i = 0
while i < len(rest):
tok = rest[i]
if tok == "--critique":
critique = True
elif tok == "--skill" and i + 1 < len(rest):
skill_name = rest[i + 1]
i += 1
elif tok.startswith("--skill="):
skill_name = tok.split("=", 1)[1]
else:
filtered.append(tok)
i += 1

if len(filtered) < 2:
_fmt(style, ("class:error",
"Usage: /deck new [--critique] [--skill <skill>] <name> \"<intent>\"\n"))
return

name = filtered[0]
intent = " ".join(filtered[1:])

# Reuse the shared safety gates from the CLI (name validation,
# wiki dir, wiki content). Chat has no -y flag, so existing decks
# block with a clear instruction to delete first.
from openkb.cli import _preflight_skill_new
err = _preflight_skill_new(kb_dir, name)
if err:
# Reword "Skill name" → "Deck name" so error matches the command.
err = err.replace("Skill name", "Deck name")
_fmt(style, ("class:error", f"[ERROR] {err}\n"))
return

from openkb.deck import deck_dir
target = deck_dir(kb_dir, name)
if target.exists():
_fmt(style, ("class:error",
f"[ERROR] output/decks/{name}/ already exists. Remove it first "
f"with `rm -rf output/decks/{name}` and re-run.\n"))
return

# Load model from KB config
from openkb.config import load_config, DEFAULT_CONFIG
config = load_config(kb_dir / ".openkb" / "config.yaml")
model = config.get("model", DEFAULT_CONFIG["model"])

from openkb.skill.generator import Generator
skill_label = skill_name if skill_name else "openkb-deck-editorial (default)"
_fmt(
style,
("class:slash.help", f"Generating deck '{name}' via skill {skill_label}...\n"),
)
gen = Generator(
target_type="deck",
name=name,
intent=intent,
kb_dir=kb_dir,
model=model,
critique=critique,
skill_name=skill_name,
)
try:
await gen.run()
except RuntimeError as exc:
_fmt(style, ("class:error", f"[ERROR] {exc}\n"))
return

# Surface validation issues from Generator.run. Unlike the CLI
# (which exits 1 on validation errors), chat is interactive — print
# issues inline and continue so the user can inspect and iterate.
result = gen.validation
if result is not None and (result.errors or result.warnings):
_fmt(style, ("class:error", "[WARN] Validation found issues:\n"))
for err in result.errors:
_fmt(style, ("class:error", f" ERROR: {err}\n"))
for warn in result.warnings:
_fmt(style, ("class:error", f" WARN: {warn}\n"))

_fmt(style, ("class:slash.ok", f"Saved: output/decks/{name}/index.html\n"))
_fmt(style, ("class:slash.help",
f"Iterate: ask follow-up questions in this chat and the agent can "
f"edit files under output/decks/{name}/ directly.\n"))


async def _handle_slash(
cmd: str,
kb_dir: Path,
Expand Down Expand Up @@ -643,13 +763,83 @@ async def _handle_slash(
await _handle_slash_skill(arg, kb_dir, style)
return None

if head == "/deck":
await _handle_slash_deck(arg, kb_dir, style)
return None

if head == "/critique":
await _handle_slash_critique(arg, kb_dir, style)
return None

_fmt(
style,
("class:error", f"Unknown command: {head}. Try /help.\n"),
)
return None


async def _handle_slash_critique(arg: str, kb_dir: Path, style: Style) -> None:
"""``/critique <path>`` — run the openkb-html-critic skill on a file.

The skill reads the HTML, fixes CSS specificity bugs / missing nav /
self-containment violations, and writes the corrected file back. It
will not touch slide content (numbers, names, quotes).
"""
path = arg.strip()
if not path:
_fmt(
style,
("class:slash.help", "Usage: /critique <path-to-html>\n"),
)
return

target = (kb_dir / path).resolve() if not Path(path).is_absolute() else Path(path)
if not target.is_file():
_fmt(style, ("class:error", f"[ERROR] File not found: {path}\n"))
return

from openkb.agent.skill_runner import (
MAX_TURNS_WITH_CRITIQUE,
SkillNotFoundError,
run_skill,
)
from openkb.config import DEFAULT_CONFIG, load_config

config = load_config(kb_dir / ".openkb" / "config.yaml")
model = config.get("model", DEFAULT_CONFIG["model"])

# Path passed to the skill is relative to kb_dir (the agent's cwd
# conceptually). The skill's read_file/write_file tools operate
# under wiki/ and output/ scopes — give it the relative form so
# write_kb_file resolves correctly.
try:
rel = target.relative_to(kb_dir)
rel_str = str(rel)
except ValueError:
# Outside KB — pass absolute, write tool will reject, but read
# may still work for a critique-only diagnostic.
rel_str = str(target)

_fmt(style, ("class:slash.ok", f"Critiquing {rel_str}...\n"))

try:
await run_skill(
skill_name="openkb-html-critic",
intent=f"Critique and patch the HTML file at: {rel_str}",
kb_dir=kb_dir,
model=model,
max_turns=40,
)
except SkillNotFoundError as exc:
_fmt(style, ("class:error", f"[ERROR] {exc}\n"))
return
except RuntimeError as exc:
_fmt(style, ("class:error", f"[ERROR] {exc}\n"))
return

_fmt(style, ("class:slash.ok", f"Critique pass complete: {rel_str}\n"))


async def run_chat(
kb_dir: Path,
session: ChatSession,
Expand Down
111 changes: 109 additions & 2 deletions openkb/agent/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,19 @@ def build_chat_agent(
language: str = "en",
) -> Agent:
"""Build the chat agent: query agent + a write tool restricted to
``<kb>/wiki/explorations/**`` and ``<kb>/output/**``.
``<kb>/wiki/explorations/**`` and ``<kb>/output/**`` + a ``ShellTool``
advertising locally-installed Anthropic-style skills.

This is the variant used by the interactive ``openkb chat`` REPL so users
can iterate on generated artifacts (e.g. ``output/skills/<name>/``) via
natural-language follow-ups without giving the agent unrestricted write
access to the wiki.

Skill discovery: ``openkb/agent/skills.scan_local_skills`` looks in
``<kb>/skills/``, ``~/.openkb/skills/``, ``~/.claude/skills/`` for
``SKILL.md`` files. Any found skill is exposed to the agent via
``ShellTool.environment.skills`` so the model can ``cat`` the skill body
and follow its instructions when the user's request matches.
"""
wiki_root = str(kb_dir / "wiki")
kb_root = str(kb_dir)
Expand All @@ -130,7 +137,107 @@ def write_file(path: str, content: str) -> str:
"""
return write_kb_file(path, content, kb_root)

return base.clone(tools=[*base.tools, write_file])
extra_tools: list = [write_file]
skill_instructions_addendum = ""

# Skill discovery via function tools. The agents SDK has a richer
# ``ShellTool``+``ShellToolLocalSkill`` mechanism for this, but those
# are OpenAI Responses-API hosted tools; LiteLLM routes through
# ChatCompletions which rejects hosted tools. So we use plain
# ``function_tool`` primitives that work with any LiteLLM-routed model.
from openkb.agent.skills import scan_local_skills

skills = scan_local_skills(kb_dir)
skill_index = {s["name"]: s for s in skills}

if skill_index:
skill_list_text = _format_skill_list(skills)

@function_tool
def list_skills() -> str:
"""List skills available in this environment.

Returns a text catalog of installed Anthropic-style skills.
Each entry has a name and a one-line description; use the
description to decide whether the skill matches the user's
request, then call ``read_skill(name)`` to load its body.
"""
return skill_list_text

@function_tool
def read_skill(name: str) -> str:
"""Read a skill's ``SKILL.md`` body.

Call this once you've decided a skill matches the user's
request. The returned text is the full skill instructions
(frontmatter stripped). Follow it as your working method
and write outputs via the ``write_file`` tool.

Args:
name: skill name as listed by ``list_skills``.
"""
entry = skill_index.get(name)
if entry is None:
return (
f"Unknown skill: {name!r}. Call list_skills() to see "
f"available skills."
)
md_path = Path(entry["path"]) / "SKILL.md"
try:
text = md_path.read_text(encoding="utf-8")
except OSError as exc:
return f"Could not read {md_path}: {exc}"
# Strip frontmatter, return body only.
from openkb.agent.skills import _parse_frontmatter
_, body = _parse_frontmatter(text)
return body

extra_tools.extend([list_skills, read_skill])

# Build the prompt addendum listing skill names + descriptions
# right inside the system prompt so the model sees them up front
# and knows what to look for, even before deciding to call
# list_skills(). This is the difference between "agent
# eventually discovers skills" and "agent treats skill use as
# the default for matching requests".
skill_lines = []
for s in skills:
desc_one_line = " ".join(s["description"].split())
skill_lines.append(f"- **{s['name']}** — {desc_one_line}")
skill_instructions_addendum = (
"\n\n## Available skills\n\n"
"The following Anthropic-style skill packages are installed in "
"this environment. **When a user request matches a skill's "
"description (e.g. 'make a deck', 'generate slides', 'draft a "
"report'), you MUST call `read_skill(name)` to load that "
"skill's full instructions and follow them strictly** — do not "
"freestyle the output format if a skill covers it.\n\n"
+ "\n".join(skill_lines)
+ "\n\nIf no listed skill matches the request, proceed with "
"your default tools."
)

new_instructions = (base.instructions or "") + skill_instructions_addendum
return base.clone(
tools=[*base.tools, *extra_tools],
instructions=new_instructions,
)


def _format_skill_list(skills: list[dict[str, str]]) -> str:
"""Render the skill catalog as a compact text block for the agent."""
if not skills:
return "No skills installed."
lines = [f"{len(skills)} skill(s) available:\n"]
for s in skills:
lines.append(f"- {s['name']}")
# Indent description; keep it one paragraph so the agent reads it fast.
desc = " ".join(s["description"].split())
lines.append(f" {desc}")
lines.append(
"\nTo use a skill, call read_skill(name) and follow its instructions."
)
return "\n".join(lines)


async def run_query(
Expand Down
Loading