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.
- okforge-0.6.2.dist-info/METADATA +144 -0
- okforge-0.6.2.dist-info/RECORD +54 -0
- okforge-0.6.2.dist-info/WHEEL +4 -0
- okforge-0.6.2.dist-info/entry_points.txt +3 -0
- okforge-0.6.2.dist-info/licenses/LICENSE +190 -0
- openkb/__init__.py +13 -0
- openkb/__main__.py +5 -0
- openkb/_skills/openkb-deck-editorial/SKILL.md +185 -0
- openkb/_skills/openkb-deck-neon/SKILL.md +300 -0
- openkb/_skills/openkb-html-critic/SKILL.md +140 -0
- openkb/agent/__init__.py +1 -0
- openkb/agent/_markdown.py +370 -0
- openkb/agent/chat.py +963 -0
- openkb/agent/chat_session.py +271 -0
- openkb/agent/compiler.py +2435 -0
- openkb/agent/linter.py +119 -0
- openkb/agent/query.py +470 -0
- openkb/agent/skill_runner.py +216 -0
- openkb/agent/skills.py +124 -0
- openkb/agent/tools.py +404 -0
- openkb/cli.py +3032 -0
- openkb/config.py +337 -0
- openkb/converter.py +270 -0
- openkb/deck/__init__.py +34 -0
- openkb/deck/creator.py +122 -0
- openkb/deck/validator.py +246 -0
- openkb/frontmatter.py +138 -0
- openkb/images.py +260 -0
- openkb/indexer.py +196 -0
- openkb/links.py +84 -0
- openkb/lint.py +694 -0
- openkb/locks.py +243 -0
- openkb/log.py +22 -0
- openkb/mutation.py +458 -0
- openkb/okf.py +120 -0
- openkb/prompts/__init__.py +22 -0
- openkb/prompts/skill_create.md +214 -0
- openkb/schema.py +93 -0
- openkb/skill/__init__.py +102 -0
- openkb/skill/creator.py +224 -0
- openkb/skill/evaluator.py +490 -0
- openkb/skill/generator.py +125 -0
- openkb/skill/marketplace.py +118 -0
- openkb/skill/tools.py +103 -0
- openkb/skill/validator.py +277 -0
- openkb/skill/workspace.py +188 -0
- openkb/state.py +127 -0
- openkb/templates/graph.html +907 -0
- openkb/topic_tree.py +229 -0
- openkb/topic_tree_llm.py +104 -0
- openkb/tree_renderer.py +170 -0
- openkb/url_ingest.py +282 -0
- openkb/visualize.py +79 -0
- 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"))
|