okforge 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. okforge-0.6.2.dist-info/METADATA +144 -0
  2. okforge-0.6.2.dist-info/RECORD +54 -0
  3. okforge-0.6.2.dist-info/WHEEL +4 -0
  4. okforge-0.6.2.dist-info/entry_points.txt +3 -0
  5. okforge-0.6.2.dist-info/licenses/LICENSE +190 -0
  6. openkb/__init__.py +13 -0
  7. openkb/__main__.py +5 -0
  8. openkb/_skills/openkb-deck-editorial/SKILL.md +185 -0
  9. openkb/_skills/openkb-deck-neon/SKILL.md +300 -0
  10. openkb/_skills/openkb-html-critic/SKILL.md +140 -0
  11. openkb/agent/__init__.py +1 -0
  12. openkb/agent/_markdown.py +370 -0
  13. openkb/agent/chat.py +963 -0
  14. openkb/agent/chat_session.py +271 -0
  15. openkb/agent/compiler.py +2435 -0
  16. openkb/agent/linter.py +119 -0
  17. openkb/agent/query.py +470 -0
  18. openkb/agent/skill_runner.py +216 -0
  19. openkb/agent/skills.py +124 -0
  20. openkb/agent/tools.py +404 -0
  21. openkb/cli.py +3032 -0
  22. openkb/config.py +337 -0
  23. openkb/converter.py +270 -0
  24. openkb/deck/__init__.py +34 -0
  25. openkb/deck/creator.py +122 -0
  26. openkb/deck/validator.py +246 -0
  27. openkb/frontmatter.py +138 -0
  28. openkb/images.py +260 -0
  29. openkb/indexer.py +196 -0
  30. openkb/links.py +84 -0
  31. openkb/lint.py +694 -0
  32. openkb/locks.py +243 -0
  33. openkb/log.py +22 -0
  34. openkb/mutation.py +458 -0
  35. openkb/okf.py +120 -0
  36. openkb/prompts/__init__.py +22 -0
  37. openkb/prompts/skill_create.md +214 -0
  38. openkb/schema.py +93 -0
  39. openkb/skill/__init__.py +102 -0
  40. openkb/skill/creator.py +224 -0
  41. openkb/skill/evaluator.py +490 -0
  42. openkb/skill/generator.py +125 -0
  43. openkb/skill/marketplace.py +118 -0
  44. openkb/skill/tools.py +103 -0
  45. openkb/skill/validator.py +277 -0
  46. openkb/skill/workspace.py +188 -0
  47. openkb/state.py +127 -0
  48. openkb/templates/graph.html +907 -0
  49. openkb/topic_tree.py +229 -0
  50. openkb/topic_tree_llm.py +104 -0
  51. openkb/tree_renderer.py +170 -0
  52. openkb/url_ingest.py +282 -0
  53. openkb/visualize.py +79 -0
  54. openkb/watcher.py +101 -0
openkb/agent/chat.py ADDED
@@ -0,0 +1,963 @@
1
+ """Interactive multi-turn chat REPL for the okforge knowledge base.
2
+
3
+ Builds on the single-shot Q&A agent in ``openkb.agent.query`` and keeps
4
+ conversation state in ``ChatSession``. Uses prompt_toolkit for the input
5
+ line (history, editing, bottom toolbar) and streams responses directly to
6
+ stdout to preserve the existing ``query`` visual.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+ import re
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from prompt_toolkit import PromptSession
20
+ from prompt_toolkit.completion import Completer, Completion, PathCompleter
21
+ from prompt_toolkit.document import Document
22
+ from prompt_toolkit.formatted_text import FormattedText
23
+ from prompt_toolkit.shortcuts import CompleteStyle, print_formatted_text
24
+ from prompt_toolkit.styles import Style
25
+
26
+ from openkb.agent.chat_session import ChatSession
27
+ from openkb.agent.query import MAX_TURNS, build_chat_agent
28
+ from openkb.log import append_log
29
+
30
+ _STYLE_DICT: dict[str, str] = {
31
+ "prompt": "bold #5fa0e0",
32
+ "bottom-toolbar": "noreverse nobold #8a8a8a bg:default",
33
+ "toolbar": "noreverse nobold #8a8a8a bg:default",
34
+ "toolbar.session": "noreverse #8a8a8a bg:default bold",
35
+ "header": "#8a8a8a",
36
+ "header.title": "bold #5fa0e0",
37
+ "tool": "#a8a8a8",
38
+ "tool.name": "#a8a8a8 bold",
39
+ "slash.ok": "ansigreen",
40
+ "slash.help": "#8a8a8a",
41
+ "error": "ansired bold",
42
+ "resume.turn": "#5fa0e0",
43
+ "resume.user": "bold",
44
+ "resume.assistant": "#8a8a8a",
45
+ # Completion menu — lightweight, no heavy background
46
+ "completion-menu": "bg:default #8a8a8a",
47
+ "completion-menu.completion": "bg:default #d0d0d0",
48
+ "completion-menu.completion.current": "bg:#3a3a3a #ffffff bold",
49
+ "completion-menu.meta.completion": "bg:default #6a6a6a",
50
+ "completion-menu.meta.completion.current": "bg:#3a3a3a #8a8a8a",
51
+ }
52
+
53
+ _HELP_TEXT = (
54
+ "Commands:\n"
55
+ " /exit Exit (Ctrl-D also works)\n"
56
+ " /clear Start a fresh session (current one is kept on disk)\n"
57
+ " /save [name] Export transcript to wiki/explorations/\n"
58
+ " /status Show knowledge base status\n"
59
+ " /list List all documents in the knowledge base\n"
60
+ " /lint Lint the knowledge base\n"
61
+ " /add <path> Add a document or directory to the knowledge base\n"
62
+ ' /skill new <name> "<intent>" Compile a skill from the wiki\n'
63
+ ' /deck new [--critique] [--skill <name>] <name> "<intent>" Generate an HTML deck from the wiki\n'
64
+ " /critique <path-to-html> Run html-critic skill on a file (CSS bugs, layout, self-containment)\n"
65
+ " /help Show this"
66
+ )
67
+
68
+ _SIGINT_EXIT_WINDOW = 2.0
69
+
70
+
71
+ def _use_color(force_off: bool) -> bool:
72
+ if force_off:
73
+ return False
74
+ if os.environ.get("NO_COLOR", ""):
75
+ return False
76
+ if not sys.stdout.isatty():
77
+ return False
78
+ return True
79
+
80
+
81
+ def _build_style(use_color: bool) -> Style:
82
+ return Style.from_dict(_STYLE_DICT if use_color else {})
83
+
84
+
85
+ def _fmt(style: Style, *fragments: tuple[str, str]) -> None:
86
+ # prompt_toolkit's print_formatted_text constructs a Win32Output on
87
+ # Windows that requires a real console handle, raising
88
+ # NoConsoleScreenBufferError when stdout is a pipe, file, or captured
89
+ # subprocess stream. Fall back to plain text when the output isn't a
90
+ # usable console.
91
+ if not _use_color(force_off=False):
92
+ for _, text in fragments:
93
+ sys.stdout.write(text)
94
+ sys.stdout.flush()
95
+ return
96
+ print_formatted_text(FormattedText(list(fragments)), style=style, end="")
97
+
98
+
99
+ def _format_tool_line(name: str, args: str, width: int = 78) -> str:
100
+ args = args or ""
101
+ args = args.replace("\n", " ")
102
+ base = f" \u00b7 {name}({args})"
103
+ if len(base) > width:
104
+ base = base[: width - 1] + "\u2026"
105
+ return base
106
+
107
+
108
+ def _extract_preview(text: str, limit: int = 150) -> str:
109
+ text = " ".join((text or "").strip().split())
110
+ if len(text) <= limit:
111
+ return text
112
+ return text[: limit - 1] + "\u2026"
113
+
114
+
115
+ def _openkb_version() -> str:
116
+ from openkb import __version__
117
+
118
+ return __version__
119
+
120
+
121
+ def _display_kb_dir(kb_dir: Path) -> str:
122
+ home = str(Path.home())
123
+ s = str(kb_dir)
124
+ if s == home:
125
+ return "~"
126
+ if s.startswith(home + "/"):
127
+ return "~" + s[len(home) :]
128
+ return s
129
+
130
+
131
+ def _print_header(session: ChatSession, kb_dir: Path, style: Style) -> None:
132
+ disp_dir = _display_kb_dir(kb_dir)
133
+ version = _openkb_version()
134
+ version_suffix = f" v{version}\n" if version else "\n"
135
+ print()
136
+ _fmt(
137
+ style,
138
+ ("class:header.title", "okforge Chat"),
139
+ ("class:header", version_suffix),
140
+ )
141
+ _fmt(
142
+ style,
143
+ (
144
+ "class:header",
145
+ f"{disp_dir} \u00b7 {session.model} \u00b7 session {session.id}\n",
146
+ ),
147
+ )
148
+ _fmt(
149
+ style,
150
+ (
151
+ "class:header",
152
+ "Type /help for commands, Ctrl-D to exit, Ctrl-C to abort current response.\n",
153
+ ),
154
+ )
155
+ print()
156
+
157
+
158
+ def _print_resume_view(session: ChatSession, style: Style) -> None:
159
+ turns = list(zip(session.user_turns, session.assistant_texts))
160
+ if not turns:
161
+ return
162
+ total = len(turns)
163
+ if total > 5:
164
+ omitted = total - 5
165
+ _fmt(
166
+ style,
167
+ ("class:header", f"... {omitted} earlier turn(s) omitted\n"),
168
+ )
169
+ turns = turns[-5:]
170
+ start = omitted + 1
171
+ else:
172
+ start = 1
173
+
174
+ _fmt(
175
+ style,
176
+ ("class:header", f"Resumed session {total} turn(s)\n"),
177
+ )
178
+ for i, (u, a) in enumerate(turns, start):
179
+ _fmt(
180
+ style,
181
+ ("class:resume.turn", f"[{i}] "),
182
+ ("class:resume.user", f">>> {u}\n"),
183
+ )
184
+ if a:
185
+ preview = _extract_preview(a, 180)
186
+ extra = ""
187
+ if len(a) > len(preview):
188
+ extra = f" ({len(a)} chars)"
189
+ _fmt(
190
+ style,
191
+ ("class:resume.turn", f"[{i}] "),
192
+ ("class:resume.assistant", f" {preview}{extra}\n"),
193
+ )
194
+ print()
195
+
196
+
197
+ def _bottom_toolbar(session: ChatSession) -> FormattedText:
198
+ return FormattedText(
199
+ [
200
+ ("class:toolbar", " session "),
201
+ ("class:toolbar.session", session.id),
202
+ (
203
+ "class:toolbar",
204
+ f" {session.turn_count} turn(s) {session.model} ",
205
+ ),
206
+ ]
207
+ )
208
+
209
+
210
+ _SLASH_COMMANDS: list[tuple[str, str]] = [
211
+ ("/exit", "Exit (Ctrl-D also works)"),
212
+ ("/quit", "Exit (alias)"),
213
+ ("/help", "Show available commands"),
214
+ ("/clear", "Start a fresh session"),
215
+ ("/save", "Export transcript to wiki/explorations/"),
216
+ ("/status", "Show knowledge base status"),
217
+ ("/list", "List all documents"),
218
+ ("/lint", "Lint the knowledge base"),
219
+ ("/add", "Add a document or directory"),
220
+ ("/skill", 'Compile a skill (try `/skill new <name> "intent"`)'),
221
+ ("/deck", 'Generate a deck (try `/deck new <name> "intent"`)'),
222
+ ("/critique", "Run html-critic skill on a file (e.g. `/critique output/decks/foo/index.html`)"),
223
+ ]
224
+
225
+
226
+ class _ChatCompleter(Completer):
227
+ """Complete slash commands and file paths after /add."""
228
+
229
+ def __init__(self) -> None:
230
+ self._path_completer = PathCompleter(expanduser=True)
231
+
232
+ def get_completions(self, document: Document, complete_event: Any) -> Any:
233
+ text = document.text_before_cursor
234
+
235
+ # After "/add ", complete file paths (skip dotfiles)
236
+ if text.lstrip().lower().startswith("/add "):
237
+ path_text = text.lstrip()[5:]
238
+ # Strip leading quote so PathCompleter resolves the real path
239
+ quote_char = ""
240
+ if path_text and path_text[0] in ("'", '"'):
241
+ quote_char = path_text[0]
242
+ path_text = path_text[1:]
243
+ path_doc = Document(path_text, len(path_text))
244
+ for c in self._path_completer.get_completions(path_doc, complete_event):
245
+ # Hide dotfiles unless the user explicitly typed a dot
246
+ basename = c.text.lstrip("/")
247
+ if basename.startswith(".") and not path_text.rpartition("/")[2].startswith("."):
248
+ continue
249
+ # Append closing quote for files; skip for directories so
250
+ # the user can keep navigating into subdirectories.
251
+ if quote_char and not c.text.endswith("/"):
252
+ comp_text = c.text + quote_char
253
+ else:
254
+ comp_text = c.text
255
+ yield Completion(
256
+ comp_text,
257
+ start_position=c.start_position,
258
+ display=c.display,
259
+ display_meta=c.display_meta,
260
+ )
261
+ return
262
+
263
+ # Complete slash commands with descriptions
264
+ if text.startswith("/"):
265
+ for cmd, desc in _SLASH_COMMANDS:
266
+ if cmd.startswith(text.lower()):
267
+ yield Completion(cmd, start_position=-len(text), display_meta=desc)
268
+
269
+
270
+ def _make_prompt_session(
271
+ session: ChatSession, style: Style, use_color: bool, kb_dir: Path
272
+ ) -> PromptSession:
273
+ from prompt_toolkit.filters import has_completions
274
+ from prompt_toolkit.history import FileHistory
275
+ from prompt_toolkit.key_binding import KeyBindings
276
+
277
+ kb = KeyBindings()
278
+
279
+ @kb.add("tab", filter=has_completions)
280
+ def _accept_completion(event: Any) -> None:
281
+ """Tab accepts the current completion (like zsh), not cycle."""
282
+ buf = event.current_buffer
283
+ state = buf.complete_state
284
+ if not state:
285
+ return
286
+ # Only one candidate or already selected — accept immediately
287
+ if state.current_completion:
288
+ buf.apply_completion(state.current_completion)
289
+ elif len(state.completions) == 1:
290
+ buf.apply_completion(state.completions[0])
291
+ else:
292
+ # Multiple candidates, nothing selected — highlight first
293
+ buf.go_to_completion(0)
294
+
295
+ @kb.add("tab", filter=~has_completions)
296
+ def _trigger_completion(event: Any) -> None:
297
+ """Tab triggers completion when menu is not open."""
298
+ buf = event.current_buffer
299
+ buf.start_completion()
300
+
301
+ history_path = kb_dir / ".openkb" / "chat_history"
302
+ return PromptSession(
303
+ message=FormattedText([("class:prompt", ">>> ")]),
304
+ style=style,
305
+ completer=_ChatCompleter(),
306
+ complete_style=CompleteStyle.MULTI_COLUMN,
307
+ complete_while_typing=False,
308
+ key_bindings=kb,
309
+ history=FileHistory(str(history_path)),
310
+ bottom_toolbar=(lambda: _bottom_toolbar(session)) if use_color else None,
311
+ )
312
+
313
+
314
+ def _make_rich_console() -> Any:
315
+ from rich.console import Console
316
+
317
+ return Console()
318
+
319
+
320
+ def _make_markdown(text: str) -> Any:
321
+ from openkb.agent._markdown import render
322
+
323
+ return render(text)
324
+
325
+
326
+ async def _run_turn(
327
+ agent: Any,
328
+ session: ChatSession,
329
+ user_input: str,
330
+ style: Style,
331
+ *,
332
+ use_color: bool = True,
333
+ raw: bool = False,
334
+ ) -> None:
335
+ """Run one agent turn with streaming output and persist the new history."""
336
+ from agents import (
337
+ RawResponsesStreamEvent,
338
+ RunItemStreamEvent,
339
+ Runner,
340
+ )
341
+ from openai.types.responses import ResponseTextDeltaEvent
342
+
343
+ new_input = session.history + [{"role": "user", "content": user_input}]
344
+
345
+ result = Runner.run_streamed(agent, new_input, max_turns=MAX_TURNS)
346
+
347
+ print()
348
+ collected: list[str] = []
349
+ segment: list[str] = []
350
+ last_was_text = False
351
+ need_blank_before_text = False
352
+
353
+ if use_color and not raw:
354
+ from rich.live import Live
355
+
356
+ console = _make_rich_console()
357
+ else:
358
+ console = None # type: ignore[assignment]
359
+
360
+ def _start_live() -> Any:
361
+ if console is None:
362
+ return None
363
+ lv = Live(console=console, vertical_overflow="visible")
364
+ lv.start()
365
+ return lv
366
+
367
+ live = _start_live()
368
+
369
+ try:
370
+ async for event in result.stream_events():
371
+ if isinstance(event, RawResponsesStreamEvent):
372
+ if isinstance(event.data, ResponseTextDeltaEvent):
373
+ text = event.data.delta
374
+ if text:
375
+ if need_blank_before_text:
376
+ if console is not None:
377
+ print()
378
+ segment = []
379
+ live = _start_live()
380
+ else:
381
+ sys.stdout.write("\n")
382
+ need_blank_before_text = False
383
+ collected.append(text)
384
+ segment.append(text)
385
+ last_was_text = True
386
+ if live:
387
+ if "\n" in text:
388
+ joined = "".join(segment)
389
+ visible = joined[: joined.rfind("\n") + 1]
390
+ if visible:
391
+ live.update(_make_markdown(visible))
392
+ else:
393
+ sys.stdout.write(text)
394
+ sys.stdout.flush()
395
+ elif isinstance(event, RunItemStreamEvent):
396
+ item = event.item
397
+ if item.type == "tool_call_item":
398
+ if last_was_text:
399
+ if live:
400
+ if segment:
401
+ live.update(_make_markdown("".join(segment)))
402
+ live.stop()
403
+ live = None
404
+ else:
405
+ sys.stdout.write("\n")
406
+ sys.stdout.flush()
407
+ last_was_text = False
408
+ raw_item = item.raw_item
409
+ name = getattr(raw_item, "name", "?")
410
+ args = getattr(raw_item, "arguments", "") or ""
411
+ if live:
412
+ live.stop()
413
+ live = None
414
+ _fmt(style, ("class:tool", _format_tool_line(name, args) + "\n"))
415
+ need_blank_before_text = True
416
+ finally:
417
+ if live:
418
+ if segment:
419
+ live.update(_make_markdown("".join(segment)))
420
+ live.stop()
421
+ print()
422
+
423
+ answer = "".join(collected).strip()
424
+ if not answer:
425
+ answer = (result.final_output or "").strip()
426
+ session.record_turn(user_input, answer, result.to_input_list())
427
+
428
+
429
+ def _save_transcript(kb_dir: Path, session: ChatSession, name: str | None) -> Path:
430
+ from openkb.lint import (
431
+ build_norm_index,
432
+ list_existing_wiki_targets,
433
+ strip_ghost_wikilinks,
434
+ )
435
+
436
+ explore_dir = kb_dir / "wiki" / "explorations"
437
+ explore_dir.mkdir(parents=True, exist_ok=True)
438
+
439
+ base = name or session.title or (session.user_turns[0] if session.user_turns else session.id)
440
+ slug = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-")[:60] or session.id
441
+ date = session.created_at[:10].replace("-", "")
442
+ path = explore_dir / f"{slug}-{date}.md"
443
+
444
+ # Strip ghost wikilinks from assistant responses (the agent's
445
+ # instructions encourage [[wikilinks]] but it can reference pages
446
+ # that don't exist on disk). User turns are written verbatim — they
447
+ # represent intentional user input, not LLM hallucination.
448
+ # Build the normalized index once and reuse for every turn — the
449
+ # whitelist is the same across the whole session.
450
+ known = list_existing_wiki_targets(kb_dir / "wiki")
451
+ norm_index = build_norm_index(known)
452
+
453
+ lines: list[str] = [
454
+ "---",
455
+ f'session: "{session.id}"',
456
+ f'model: "{session.model}"',
457
+ f'created: "{session.created_at}"',
458
+ "---",
459
+ "",
460
+ f"# Chat transcript {session.title or session.id}",
461
+ "",
462
+ ]
463
+ for i, (u, a) in enumerate(zip(session.user_turns, session.assistant_texts), 1):
464
+ lines.append(f"## [{i}] {u}")
465
+ lines.append("")
466
+ if a:
467
+ cleaned_a, _ = strip_ghost_wikilinks(a, known, norm_index=norm_index)
468
+ lines.append(cleaned_a)
469
+ else:
470
+ lines.append("_(no response recorded)_")
471
+ lines.append("")
472
+
473
+ path.write_text("\n".join(lines), encoding="utf-8")
474
+ return path
475
+
476
+
477
+ async def _run_add(arg: str, kb_dir: Path, style: Style) -> None:
478
+ """Add a document or directory to the knowledge base from the chat REPL."""
479
+ from openkb.cli import SUPPORTED_EXTENSIONS, add_single_file
480
+
481
+ target = Path(arg).expanduser()
482
+ if not target.is_absolute():
483
+ target = Path.cwd() / target
484
+ target = target.resolve()
485
+
486
+ if not target.exists():
487
+ _fmt(style, ("class:error", f"Path does not exist: {arg}\n"))
488
+ return
489
+
490
+ if target.is_dir():
491
+ files = [
492
+ f
493
+ for f in sorted(target.rglob("*"))
494
+ if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS
495
+ ]
496
+ if not files:
497
+ _fmt(style, ("class:error", f"No supported files found in {arg}.\n"))
498
+ return
499
+ total = len(files)
500
+ _fmt(style, ("class:slash.help", f"Found {total} supported file(s) in {arg}.\n"))
501
+ for i, f in enumerate(files, 1):
502
+ _fmt(style, ("class:slash.help", f"\n[{i}/{total}] "))
503
+ await asyncio.to_thread(add_single_file, f, kb_dir)
504
+ else:
505
+ if target.suffix.lower() not in SUPPORTED_EXTENSIONS:
506
+ _fmt(style, ("class:error", f"Unsupported file type: {target.suffix}\n"))
507
+ return
508
+ await asyncio.to_thread(add_single_file, target, kb_dir)
509
+
510
+
511
+ async def _handle_slash_skill(arg: str, kb_dir: Path, style: Style) -> None:
512
+ """Dispatch ``/skill new <name> "<intent>"`` and any future skill subcommands."""
513
+ import shlex
514
+
515
+ try:
516
+ parts = shlex.split(arg) if arg else []
517
+ except ValueError as exc:
518
+ _fmt(style, ("class:error", f"[ERROR] Could not parse: {exc}\n"))
519
+ return
520
+ if not parts:
521
+ _fmt(style, ("class:error", 'Usage: /skill new <name> "<intent>"\n'))
522
+ return
523
+
524
+ sub = parts[0].lower()
525
+ if sub != "new":
526
+ _fmt(style, ("class:error", f"Unknown skill subcommand: {sub}. Try /skill new.\n"))
527
+ return
528
+
529
+ if len(parts) < 3:
530
+ _fmt(style, ("class:error", 'Usage: /skill new <name> "<intent>"\n'))
531
+ return
532
+
533
+ name = parts[1]
534
+ intent = " ".join(parts[2:])
535
+
536
+ # Use the same safety gates as the CLI (name validation, wiki dir,
537
+ # wiki content). Chat doesn't have a -y flag, so existing skills
538
+ # block with a clear instruction to delete first.
539
+ from openkb.cli import _preflight_skill_new
540
+
541
+ err = _preflight_skill_new(kb_dir, name)
542
+ if err:
543
+ _fmt(style, ("class:error", f"[ERROR] {err}\n"))
544
+ return
545
+
546
+ from openkb.skill import skill_dir
547
+
548
+ target = skill_dir(kb_dir, name)
549
+ if target.exists():
550
+ _fmt(
551
+ style,
552
+ (
553
+ "class:error",
554
+ f"[ERROR] output/skills/{name}/ already exists. Remove it first "
555
+ f"with `rm -rf output/skills/{name}` and re-run.\n",
556
+ ),
557
+ )
558
+ return
559
+
560
+ # Load model from KB config
561
+ from openkb.config import DEFAULT_CONFIG, load_config
562
+
563
+ config = load_config(kb_dir / ".openkb" / "config.yaml")
564
+ model = config.get("model", DEFAULT_CONFIG["model"])
565
+
566
+ from openkb.skill.generator import Generator
567
+
568
+ _fmt(style, ("class:slash.help", f"Compiling skill '{name}'...\n"))
569
+ gen = Generator(
570
+ target_type="skill",
571
+ name=name,
572
+ intent=intent,
573
+ kb_dir=kb_dir,
574
+ model=model,
575
+ )
576
+ try:
577
+ await gen.run()
578
+ except RuntimeError as exc:
579
+ _fmt(style, ("class:error", f"[ERROR] {exc}\n"))
580
+ return
581
+
582
+ # Surface validation issues from Generator.run. Unlike the CLI
583
+ # (which exits 1 on validation errors), chat is interactive — print
584
+ # issues inline and continue so the user can inspect and iterate.
585
+ result = gen.validation
586
+ if result is not None and (result.errors or result.warnings):
587
+ _fmt(style, ("class:error", "[WARN] Validation found issues:\n"))
588
+ for err in result.errors:
589
+ _fmt(style, ("class:error", f" ERROR: {err}\n"))
590
+ for warn in result.warnings:
591
+ _fmt(style, ("class:error", f" WARN: {warn}\n"))
592
+ _fmt(
593
+ style,
594
+ (
595
+ "class:slash.help",
596
+ f"Run `openkb skill validate {name}` to re-check, or "
597
+ f"`openkb skill rollback {name}` to revert.\n",
598
+ ),
599
+ )
600
+
601
+ _fmt(style, ("class:slash.ok", f"Saved: output/skills/{name}/\n"))
602
+ _fmt(
603
+ style,
604
+ (
605
+ "class:slash.help",
606
+ f"Iterate: ask follow-up questions in this chat and the agent can "
607
+ f"edit files under output/skills/{name}/ directly.\n",
608
+ ),
609
+ )
610
+
611
+
612
+ async def _handle_slash_deck(arg: str, kb_dir: Path, style: Style) -> None:
613
+ """Dispatch ``/deck new [--critique] <name> "<intent>"``.
614
+
615
+ Mirrors :func:`_handle_slash_skill`: validates the name, runs the
616
+ same wiki preflight gate, refuses to overwrite an existing deck
617
+ (chat has no ``-y`` flag), then invokes ``Generator(target_type="deck")``.
618
+ """
619
+ import shlex
620
+
621
+ try:
622
+ parts = shlex.split(arg) if arg else []
623
+ except ValueError as exc:
624
+ _fmt(style, ("class:error", f"[ERROR] Could not parse: {exc}\n"))
625
+ return
626
+ if not parts:
627
+ _fmt(style, ("class:error", 'Usage: /deck new [--critique] <name> "<intent>"\n'))
628
+ return
629
+
630
+ sub = parts[0].lower()
631
+ if sub != "new":
632
+ _fmt(style, ("class:error", f"Unknown deck subcommand: {sub}. Try /deck new.\n"))
633
+ return
634
+
635
+ # Parse optional --critique flag and --skill <name> option. Both can
636
+ # appear anywhere among the remaining tokens.
637
+ rest = parts[1:]
638
+ critique = False
639
+ skill_name: str | None = None
640
+ filtered: list[str] = []
641
+ i = 0
642
+ while i < len(rest):
643
+ tok = rest[i]
644
+ if tok == "--critique":
645
+ critique = True
646
+ elif tok == "--skill" and i + 1 < len(rest):
647
+ skill_name = rest[i + 1]
648
+ i += 1
649
+ elif tok.startswith("--skill="):
650
+ skill_name = tok.split("=", 1)[1]
651
+ else:
652
+ filtered.append(tok)
653
+ i += 1
654
+
655
+ if len(filtered) < 2:
656
+ _fmt(
657
+ style,
658
+ ("class:error", 'Usage: /deck new [--critique] [--skill <skill>] <name> "<intent>"\n'),
659
+ )
660
+ return
661
+
662
+ name = filtered[0]
663
+ intent = " ".join(filtered[1:])
664
+
665
+ # Reuse the shared safety gates from the CLI (name validation,
666
+ # wiki dir, wiki content). Chat has no -y flag, so existing decks
667
+ # block with a clear instruction to delete first.
668
+ from openkb.cli import _preflight_skill_new
669
+
670
+ err = _preflight_skill_new(kb_dir, name)
671
+ if err:
672
+ # Reword "Skill name" → "Deck name" so error matches the command.
673
+ err = err.replace("Skill name", "Deck name")
674
+ _fmt(style, ("class:error", f"[ERROR] {err}\n"))
675
+ return
676
+
677
+ from openkb.deck import deck_dir
678
+
679
+ target = deck_dir(kb_dir, name)
680
+ if target.exists():
681
+ _fmt(
682
+ style,
683
+ (
684
+ "class:error",
685
+ f"[ERROR] output/decks/{name}/ already exists. Remove it first "
686
+ f"with `rm -rf output/decks/{name}` and re-run.\n",
687
+ ),
688
+ )
689
+ return
690
+
691
+ # Load model from KB config
692
+ from openkb.config import DEFAULT_CONFIG, load_config
693
+
694
+ config = load_config(kb_dir / ".openkb" / "config.yaml")
695
+ model = config.get("model", DEFAULT_CONFIG["model"])
696
+
697
+ from openkb.deck.creator import DEFAULT_DECK_SKILL
698
+ from openkb.skill.generator import Generator
699
+
700
+ skill_label = skill_name if skill_name else f"{DEFAULT_DECK_SKILL} (default)"
701
+ _fmt(
702
+ style,
703
+ ("class:slash.help", f"Generating deck '{name}' via skill {skill_label}...\n"),
704
+ )
705
+ gen = Generator(
706
+ target_type="deck",
707
+ name=name,
708
+ intent=intent,
709
+ kb_dir=kb_dir,
710
+ model=model,
711
+ critique=critique,
712
+ skill_name=skill_name,
713
+ )
714
+ try:
715
+ await gen.run()
716
+ except RuntimeError as exc:
717
+ _fmt(style, ("class:error", f"[ERROR] {exc}\n"))
718
+ return
719
+
720
+ # Surface validation issues from Generator.run. Unlike the CLI
721
+ # (which exits 1 on validation errors), chat is interactive — print
722
+ # issues inline and continue so the user can inspect and iterate.
723
+ result = gen.validation
724
+ if result is not None and (result.errors or result.warnings):
725
+ _fmt(style, ("class:error", "[WARN] Validation found issues:\n"))
726
+ for err in result.errors:
727
+ _fmt(style, ("class:error", f" ERROR: {err}\n"))
728
+ for warn in result.warnings:
729
+ _fmt(style, ("class:error", f" WARN: {warn}\n"))
730
+
731
+ _fmt(style, ("class:slash.ok", f"Saved: output/decks/{name}/index.html\n"))
732
+ _fmt(
733
+ style,
734
+ (
735
+ "class:slash.help",
736
+ f"Iterate: ask follow-up questions in this chat and the agent can "
737
+ f"edit files under output/decks/{name}/ directly.\n",
738
+ ),
739
+ )
740
+
741
+
742
+ async def _handle_slash(
743
+ cmd: str,
744
+ kb_dir: Path,
745
+ session: ChatSession,
746
+ style: Style,
747
+ ) -> str | None:
748
+ """Return ``"exit"`` to end the REPL, ``"new_session"`` to swap sessions,
749
+ or ``None`` to continue with the current session."""
750
+ parts = cmd.split(maxsplit=1)
751
+ head = parts[0].lower()
752
+ arg = parts[1].strip() if len(parts) > 1 else ""
753
+ # Strip surrounding quotes (user may type /add '/path/to file')
754
+ if len(arg) >= 2 and arg[0] == arg[-1] and arg[0] in ("'", '"'):
755
+ arg = arg[1:-1]
756
+ elif arg and arg[0] in ("'", '"'):
757
+ arg = arg[1:]
758
+
759
+ if head in ("/exit", "/quit"):
760
+ _fmt(style, ("class:header", "Bye. Thanks for using okforge.\n\n"))
761
+ return "exit"
762
+
763
+ if head == "/help":
764
+ _fmt(style, ("class:slash.help", _HELP_TEXT + "\n"))
765
+ return None
766
+
767
+ if head == "/clear":
768
+ old_id = session.id
769
+ _fmt(
770
+ style,
771
+ ("class:slash.ok", f"Started new session (previous: {old_id})\n"),
772
+ )
773
+ return "new_session"
774
+
775
+ if head == "/save":
776
+ if not session.user_turns:
777
+ _fmt(style, ("class:error", "Nothing to save yet.\n"))
778
+ return None
779
+ from openkb.locks import kb_ingest_lock
780
+
781
+ with kb_ingest_lock(kb_dir / ".openkb"):
782
+ path = _save_transcript(kb_dir, session, arg or None)
783
+ _fmt(style, ("class:slash.ok", f"Saved to {path}\n"))
784
+ return None
785
+
786
+ if head == "/status":
787
+ from openkb.cli import print_status
788
+ from openkb.locks import kb_read_lock
789
+
790
+ with kb_read_lock(kb_dir / ".openkb"):
791
+ print_status(kb_dir)
792
+ return None
793
+
794
+ if head == "/list":
795
+ from openkb.cli import print_list
796
+ from openkb.locks import kb_read_lock
797
+
798
+ with kb_read_lock(kb_dir / ".openkb"):
799
+ print_list(kb_dir)
800
+ return None
801
+
802
+ if head == "/lint":
803
+ from openkb.cli import run_lint
804
+
805
+ await run_lint(kb_dir)
806
+ return None
807
+
808
+ if head == "/add":
809
+ if not arg:
810
+ _fmt(style, ("class:error", "Usage: /add <path>\n"))
811
+ return None
812
+ await _run_add(arg, kb_dir, style)
813
+ return None
814
+
815
+ if head == "/skill":
816
+ await _handle_slash_skill(arg, kb_dir, style)
817
+ return None
818
+
819
+ if head == "/deck":
820
+ await _handle_slash_deck(arg, kb_dir, style)
821
+ return None
822
+
823
+ if head == "/critique":
824
+ await _handle_slash_critique(arg, kb_dir, style)
825
+ return None
826
+
827
+ _fmt(
828
+ style,
829
+ ("class:error", f"Unknown command: {head}. Try /help.\n"),
830
+ )
831
+ return None
832
+
833
+
834
+ async def _handle_slash_critique(arg: str, kb_dir: Path, style: Style) -> None:
835
+ """``/critique <path>`` — run the openkb-html-critic skill on a file.
836
+
837
+ The skill reads the HTML, fixes CSS specificity bugs / missing nav /
838
+ self-containment violations, and writes the corrected file back. It
839
+ will not touch slide content (numbers, names, quotes).
840
+ """
841
+ path = arg.strip()
842
+ if not path:
843
+ _fmt(
844
+ style,
845
+ ("class:slash.help", "Usage: /critique <path-to-html>\n"),
846
+ )
847
+ return
848
+
849
+ target = (kb_dir / path).resolve() if not Path(path).is_absolute() else Path(path)
850
+ if not target.is_file():
851
+ _fmt(style, ("class:error", f"[ERROR] File not found: {path}\n"))
852
+ return
853
+
854
+ from openkb.agent.skill_runner import (
855
+ SkillNotFoundError,
856
+ run_skill,
857
+ )
858
+ from openkb.config import DEFAULT_CONFIG, load_config
859
+
860
+ config = load_config(kb_dir / ".openkb" / "config.yaml")
861
+ model = config.get("model", DEFAULT_CONFIG["model"])
862
+
863
+ # Path passed to the skill is relative to kb_dir (the agent's cwd
864
+ # conceptually). The skill's read_file/write_file tools operate
865
+ # under wiki/ and output/ scopes — give it the relative form so
866
+ # write_kb_file resolves correctly.
867
+ try:
868
+ rel = target.relative_to(kb_dir)
869
+ rel_str = str(rel)
870
+ except ValueError:
871
+ # Outside KB — pass absolute, write tool will reject, but read
872
+ # may still work for a critique-only diagnostic.
873
+ rel_str = str(target)
874
+
875
+ _fmt(style, ("class:slash.ok", f"Critiquing {rel_str}...\n"))
876
+
877
+ try:
878
+ await run_skill(
879
+ skill_name="openkb-html-critic",
880
+ intent=f"Critique and patch the HTML file at: {rel_str}",
881
+ kb_dir=kb_dir,
882
+ model=model,
883
+ max_turns=40,
884
+ )
885
+ except SkillNotFoundError as exc:
886
+ _fmt(style, ("class:error", f"[ERROR] {exc}\n"))
887
+ return
888
+ except RuntimeError as exc:
889
+ _fmt(style, ("class:error", f"[ERROR] {exc}\n"))
890
+ return
891
+
892
+ _fmt(style, ("class:slash.ok", f"Critique pass complete: {rel_str}\n"))
893
+
894
+
895
+ async def run_chat(
896
+ kb_dir: Path,
897
+ session: ChatSession,
898
+ *,
899
+ no_color: bool = False,
900
+ raw: bool = False,
901
+ ) -> None:
902
+ """Run the chat REPL against ``session`` until the user exits."""
903
+ from openkb.config import load_config
904
+
905
+ use_color = _use_color(force_off=no_color)
906
+ style = _build_style(use_color)
907
+
908
+ config = load_config(kb_dir / ".openkb" / "config.yaml")
909
+ language = session.language or config.get("language", "en")
910
+ agent = build_chat_agent(kb_dir, session.model, language=language)
911
+
912
+ _print_header(session, kb_dir, style)
913
+ if session.turn_count > 0:
914
+ _print_resume_view(session, style)
915
+
916
+ prompt_session = _make_prompt_session(session, style, use_color, kb_dir)
917
+
918
+ last_sigint = 0.0
919
+
920
+ while True:
921
+ try:
922
+ user_input = await prompt_session.prompt_async()
923
+ last_sigint = 0.0
924
+ except KeyboardInterrupt:
925
+ now = time.monotonic()
926
+ if last_sigint and (now - last_sigint) < _SIGINT_EXIT_WINDOW:
927
+ _fmt(style, ("class:header", "\nBye. Thanks for using okforge.\n\n"))
928
+ return
929
+ last_sigint = now
930
+ _fmt(style, ("class:header", "\n(Press Ctrl-C again to exit)\n"))
931
+ continue
932
+ except EOFError:
933
+ _fmt(style, ("class:header", "Bye. Thanks for using okforge.\n\n"))
934
+ return
935
+
936
+ user_input = (user_input or "").strip()
937
+ if not user_input:
938
+ continue
939
+
940
+ if user_input.startswith("/"):
941
+ try:
942
+ action = await _handle_slash(user_input, kb_dir, session, style)
943
+ except KeyboardInterrupt:
944
+ _fmt(style, ("class:error", "\n[aborted]\n"))
945
+ continue
946
+ if action == "exit":
947
+ return
948
+ if action == "new_session":
949
+ session = ChatSession.new(kb_dir, session.model, session.language)
950
+ agent = build_chat_agent(kb_dir, session.model, language=language)
951
+ prompt_session = _make_prompt_session(session, style, use_color, kb_dir)
952
+ continue
953
+
954
+ from openkb.locks import kb_ingest_lock
955
+
956
+ with kb_ingest_lock(kb_dir / ".openkb"):
957
+ append_log(kb_dir / "wiki", "query", user_input)
958
+ try:
959
+ await _run_turn(agent, session, user_input, style, use_color=use_color, raw=raw)
960
+ except KeyboardInterrupt:
961
+ _fmt(style, ("class:error", "\n[aborted]\n"))
962
+ except Exception as exc:
963
+ _fmt(style, ("class:error", f"[ERROR] {exc}\n"))