okf-kit 0.1.0__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.
okf_kit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """okf-kit — turn any website into a portable, agent-ready OKF bundle."""
2
+
3
+ __version__ = "0.1.0"
okf_kit/bundle_nav.py ADDED
@@ -0,0 +1,97 @@
1
+ """Bundle navigation primitives shared by chat and the MCP server.
2
+
3
+ These are the tools an agent uses to walk an OKF bundle — the same
4
+ list_directory / read_concept pair proven to let independent agents navigate
5
+ calknowledge bundles, plus a no-index keyword search. All are
6
+ path-traversal-guarded.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from .config import STATE_DIRNAME
15
+ from .okf import RESERVED
16
+
17
+ MAX_FILE_CHARS = 12000
18
+ _TOKEN = re.compile(r"[a-z0-9]{2,}")
19
+
20
+
21
+ def _safe(bundle_dir: Path, path: str) -> Path | None:
22
+ target = (bundle_dir / path.strip().lstrip("/")).resolve()
23
+ root = bundle_dir.resolve()
24
+ if target == root or root in target.parents:
25
+ return target
26
+ return None
27
+
28
+
29
+ def list_directory(bundle_dir, path: str = "/") -> str:
30
+ bundle_dir = Path(bundle_dir)
31
+ target = _safe(bundle_dir, path)
32
+ if not target or not target.is_dir():
33
+ return f"error: {path} is not a directory in this bundle. Try /index.md."
34
+ entries = sorted(
35
+ ("dir: " if p.is_dir() else "file: ") + p.name
36
+ for p in target.iterdir()
37
+ if p.name != STATE_DIRNAME
38
+ )
39
+ return "\n".join(entries) or "(empty)"
40
+
41
+
42
+ def read_concept(bundle_dir, path: str) -> str:
43
+ bundle_dir = Path(bundle_dir)
44
+ target = _safe(bundle_dir, path)
45
+ if not target or not target.is_file():
46
+ return f"error: {path} is not a file in this bundle. Read /index.md to see what exists."
47
+ return target.read_text(encoding="utf8")[:MAX_FILE_CHARS]
48
+
49
+
50
+ def _concept_files(bundle_dir):
51
+ for md in (Path(bundle_dir) / "pages").rglob("*.md"):
52
+ if md.name not in RESERVED:
53
+ yield md
54
+
55
+
56
+ def _split_frontmatter(text: str) -> tuple[str | None, str]:
57
+ """Return (title, body-without-frontmatter)."""
58
+ title = None
59
+ body = text
60
+ if text.startswith("---\n"):
61
+ end = text.find("\n---\n", 4)
62
+ if end != -1:
63
+ fm = text[4:end]
64
+ m = re.search(r"^title:\s*(.+)$", fm, re.MULTILINE)
65
+ if m:
66
+ title = m.group(1).strip()
67
+ body = text[end + 5 :]
68
+ return title, body
69
+
70
+
71
+ def search_bundle(bundle_dir, query: str, limit: int = 5) -> list[dict]:
72
+ """Keyword search over concept files (title-boosted). No index needed."""
73
+ bundle_dir = Path(bundle_dir)
74
+ terms = set(_TOKEN.findall(query.lower()))
75
+ if not terms:
76
+ return []
77
+ results = []
78
+ for md in _concept_files(bundle_dir):
79
+ title, body = _split_frontmatter(md.read_text(encoding="utf8"))
80
+ body_l = body.lower()
81
+ title_l = (title or "").lower()
82
+ score = sum(body_l.count(t) + 3 * title_l.count(t) for t in terms)
83
+ if score:
84
+ rel = "/" + str(md.relative_to(bundle_dir))
85
+ results.append({"path": rel, "title": title, "score": score,
86
+ "snippet": _snippet(body, terms)})
87
+ results.sort(key=lambda r: -r["score"])
88
+ return results[:limit]
89
+
90
+
91
+ def _snippet(text: str, terms: set[str], width: int = 220) -> str:
92
+ lower = text.lower()
93
+ pos = min((lower.find(t) for t in terms if lower.find(t) >= 0), default=-1)
94
+ if pos < 0:
95
+ return ""
96
+ start = max(0, pos - width // 3)
97
+ return re.sub(r"\s+", " ", text[start : start + width]).strip()
@@ -0,0 +1 @@
1
+ """Chat with a bundle (M3): providers, REPL, local history."""
okf_kit/chat/agent.py ADDED
@@ -0,0 +1,92 @@
1
+ """The bundle-navigation agent: answer a question by walking the OKF bundle.
2
+
3
+ Ported from calknowledge's OKF-mode agent (verified to navigate large bundles
4
+ correctly with independent LLMs): seed the root index, descend directory
5
+ indexes to the most specific concept, answer only from what was read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from ..bundle_nav import list_directory, read_concept
13
+
14
+ MAX_STEPS = 16
15
+
16
+ SYSTEM = """\
17
+ You are a knowledge agent answering questions from an OKF bundle: a directory
18
+ of markdown concept files with YAML frontmatter. Every directory has an
19
+ index.md listing its contents.
20
+
21
+ Navigate with the tools:
22
+ - list_directory(path): list a directory (e.g. "/", "/pages", "/pages/docs")
23
+ - read_concept(path): read a file (e.g. "/pages/docs/intro.md")
24
+
25
+ Strategy: the root index is provided below. Descend through directory indexes
26
+ to the MOST SPECIFIC concept for the question — don't stop at a general page
27
+ when a dedicated one exists. If a tool errors, read the nearest parent
28
+ index.md. Answer ONLY from concept files you have read; if the bundle doesn't
29
+ contain the answer, say so. Cite the bundle paths you used."""
30
+
31
+ TOOLS = [
32
+ {
33
+ "type": "function",
34
+ "function": {
35
+ "name": "list_directory",
36
+ "description": "List entries in a directory of the OKF bundle.",
37
+ "parameters": {
38
+ "type": "object",
39
+ "properties": {"path": {"type": "string", "description": "Bundle path, e.g. /pages"}},
40
+ "required": ["path"],
41
+ },
42
+ },
43
+ },
44
+ {
45
+ "type": "function",
46
+ "function": {
47
+ "name": "read_concept",
48
+ "description": "Read a markdown concept file from the OKF bundle.",
49
+ "parameters": {
50
+ "type": "object",
51
+ "properties": {"path": {"type": "string", "description": "Bundle path, e.g. /index.md"}},
52
+ "required": ["path"],
53
+ },
54
+ },
55
+ },
56
+ ]
57
+
58
+
59
+ def ask(bundle_dir, question: str, provider, *, max_steps: int = MAX_STEPS) -> dict:
60
+ """Run the navigation loop; returns {answer, steps, sources}."""
61
+ bundle_dir = Path(bundle_dir)
62
+ root_index = read_concept(bundle_dir, "/index.md")
63
+ messages = [
64
+ {"role": "system", "content": SYSTEM},
65
+ {"role": "user", "content": f"Question: {question}\n\nBundle root index (/index.md):\n{root_index}"},
66
+ ]
67
+ steps: list[dict] = []
68
+ read_paths: list[str] = []
69
+ answer = None
70
+
71
+ for _ in range(max_steps):
72
+ turn = provider.complete(messages, TOOLS)
73
+ messages.append(provider.assistant_message(turn))
74
+ if not turn.tool_calls:
75
+ answer = turn.text
76
+ break
77
+ for call in turn.tool_calls:
78
+ path = call.arguments.get("path", "/")
79
+ if call.name == "read_concept":
80
+ result = read_concept(bundle_dir, path)
81
+ if not result.startswith("error:") and path not in read_paths:
82
+ read_paths.append(path)
83
+ else:
84
+ result = list_directory(bundle_dir, path)
85
+ steps.append({"tool": call.name, "path": path})
86
+ messages.append(provider.tool_result_message(call, result))
87
+
88
+ if answer is None:
89
+ messages.append({"role": "user", "content": "Stop exploring and answer from what you've read."})
90
+ answer = provider.complete(messages, []).text
91
+
92
+ return {"answer": answer or "", "steps": steps, "sources": read_paths}
@@ -0,0 +1,51 @@
1
+ """Local chat history: one JSONL session file per conversation.
2
+
3
+ Stored under ~/.okf/chats/<bundle>/<timestamp>.jsonl — nothing leaves the
4
+ machine except the provider call itself.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from ..config import chats_dir
12
+ from ..model import utcnow_iso
13
+
14
+
15
+ class History:
16
+ def __init__(self, bundle_name: str, session: str | None = None):
17
+ self.dir = chats_dir() / bundle_name
18
+ self.dir.mkdir(parents=True, exist_ok=True)
19
+ if session:
20
+ self.path = self.dir / f"{session}.jsonl"
21
+ else:
22
+ self.path = self.dir / f"{utcnow_iso().replace(':', '-')}.jsonl"
23
+
24
+ def append(self, role: str, content: str, meta: dict | None = None) -> None:
25
+ rec = {"ts": utcnow_iso(), "role": role, "content": content}
26
+ if meta:
27
+ rec["meta"] = meta
28
+ with self.path.open("a", encoding="utf8") as fh:
29
+ fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
30
+
31
+ def load(self) -> list[dict]:
32
+ if not self.path.exists():
33
+ return []
34
+ return [json.loads(line) for line in self.path.read_text(encoding="utf8").splitlines() if line.strip()]
35
+
36
+ @classmethod
37
+ def latest(cls, bundle_name: str) -> "History | None":
38
+ d = chats_dir() / bundle_name
39
+ sessions = sorted(d.glob("*.jsonl")) if d.exists() else []
40
+ if not sessions:
41
+ return None
42
+ return cls(bundle_name, session=sessions[-1].stem)
43
+
44
+ @classmethod
45
+ def list_sessions(cls, bundle_name: str) -> list[tuple[str, int]]:
46
+ d = chats_dir() / bundle_name
47
+ out = []
48
+ for f in sorted(d.glob("*.jsonl")) if d.exists() else []:
49
+ turns = sum(1 for line in f.read_text(encoding="utf8").splitlines() if '"role": "user"' in line)
50
+ out.append((f.stem, turns))
51
+ return out
@@ -0,0 +1,161 @@
1
+ """LLM provider abstraction for chat — deliberately tiny (no LangChain).
2
+
3
+ One protocol, two implementations:
4
+ OpenAICompatProvider — any OpenAI-compatible endpoint (OpenAI, Ollama,
5
+ vLLM, LM Studio, OpenRouter). okf-kit[chat].
6
+ AnthropicProvider — Claude with tool use. okf-kit[anthropic].
7
+
8
+ `make_provider` returns None when no provider/key is available, so the caller
9
+ falls back to zero-key retrieval.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+
17
+ # Preset endpoints for OpenAI-compatible servers.
18
+ _PRESETS = {
19
+ "openai": {"base_url": None, "key_env": "OPENAI_API_KEY", "default_model": "gpt-4o-mini"},
20
+ "ollama": {"base_url": "http://localhost:11434/v1", "key_env": None, "default_model": "llama3.1"},
21
+ "openrouter": {"base_url": "https://openrouter.ai/api/v1", "key_env": "OPENROUTER_API_KEY",
22
+ "default_model": "openai/gpt-4o-mini"},
23
+ }
24
+
25
+
26
+ @dataclass
27
+ class ToolCall:
28
+ id: str
29
+ name: str
30
+ arguments: dict
31
+
32
+
33
+ @dataclass
34
+ class Turn:
35
+ text: str | None = None
36
+ tool_calls: list[ToolCall] = field(default_factory=list)
37
+
38
+
39
+ class OpenAICompatProvider:
40
+ def __init__(self, model: str, base_url: str | None, api_key: str):
41
+ try:
42
+ from openai import OpenAI
43
+ except ImportError as exc: # noqa: TRY003
44
+ raise SystemExit("Chat needs the chat extra: pip install 'okf-kit[chat]'") from exc
45
+ self.model = model
46
+ self._client = OpenAI(base_url=base_url, api_key=api_key)
47
+
48
+ def complete(self, messages: list[dict], tools: list[dict]) -> Turn:
49
+ resp = self._client.chat.completions.create(
50
+ model=self.model, messages=messages, tools=tools or None
51
+ )
52
+ msg = resp.choices[0].message
53
+ calls = [
54
+ ToolCall(id=tc.id, name=tc.function.name, arguments=_loads(tc.function.arguments))
55
+ for tc in (msg.tool_calls or [])
56
+ ]
57
+ return Turn(text=msg.content, tool_calls=calls)
58
+
59
+ def assistant_message(self, turn: Turn) -> dict:
60
+ m: dict = {"role": "assistant", "content": turn.text or ""}
61
+ if turn.tool_calls:
62
+ m["tool_calls"] = [
63
+ {"id": c.id, "type": "function",
64
+ "function": {"name": c.name, "arguments": _dumps(c.arguments)}}
65
+ for c in turn.tool_calls
66
+ ]
67
+ return m
68
+
69
+ @staticmethod
70
+ def tool_result_message(call: ToolCall, content: str) -> dict:
71
+ return {"role": "tool", "tool_call_id": call.id, "content": content}
72
+
73
+
74
+ class AnthropicProvider:
75
+ def __init__(self, model: str, api_key: str):
76
+ try:
77
+ from anthropic import Anthropic
78
+ except ImportError as exc: # noqa: TRY003
79
+ raise SystemExit("Claude chat needs: pip install 'okf-kit[anthropic]'") from exc
80
+ self.model = model
81
+ self._client = Anthropic(api_key=api_key)
82
+ self._system = None
83
+
84
+ def complete(self, messages: list[dict], tools: list[dict]) -> Turn:
85
+ system, msgs = _split_system(messages)
86
+ atools = [
87
+ {"name": t["function"]["name"], "description": t["function"]["description"],
88
+ "input_schema": t["function"]["parameters"]}
89
+ for t in tools
90
+ ]
91
+ resp = self._client.messages.create(
92
+ model=self.model, max_tokens=2048, system=system or "", messages=msgs, tools=atools or []
93
+ )
94
+ text_parts, calls = [], []
95
+ for block in resp.content:
96
+ if block.type == "text":
97
+ text_parts.append(block.text)
98
+ elif block.type == "tool_use":
99
+ calls.append(ToolCall(id=block.id, name=block.name, arguments=dict(block.input)))
100
+ return Turn(text="".join(text_parts) or None, tool_calls=calls)
101
+
102
+ def assistant_message(self, turn: Turn) -> dict:
103
+ content: list[dict] = []
104
+ if turn.text:
105
+ content.append({"type": "text", "text": turn.text})
106
+ for c in turn.tool_calls:
107
+ content.append({"type": "tool_use", "id": c.id, "name": c.name, "input": c.arguments})
108
+ return {"role": "assistant", "content": content}
109
+
110
+ @staticmethod
111
+ def tool_result_message(call: ToolCall, content: str) -> dict:
112
+ return {"role": "user", "content": [
113
+ {"type": "tool_result", "tool_use_id": call.id, "content": content}]}
114
+
115
+
116
+ def make_provider(provider: str | None, model: str | None, base_url: str | None):
117
+ """Return a provider instance, or None to use the zero-key retrieval fallback."""
118
+ if provider in (None, "none"):
119
+ return None
120
+ if provider == "anthropic":
121
+ key = os.environ.get("ANTHROPIC_API_KEY")
122
+ if not key:
123
+ return None
124
+ return AnthropicProvider(model or "claude-sonnet-5", key)
125
+ if provider in _PRESETS or provider == "custom":
126
+ preset = _PRESETS.get(provider, {})
127
+ url = base_url or preset.get("base_url")
128
+ key_env = preset.get("key_env")
129
+ key = os.environ.get(key_env) if key_env else None
130
+ if provider == "ollama":
131
+ key = key or "ollama" # Ollama ignores the key but the SDK requires one
132
+ if not key:
133
+ return None
134
+ return OpenAICompatProvider(model or preset.get("default_model", "gpt-4o-mini"), url, key)
135
+ raise SystemExit(f"Unknown provider '{provider}'. Use openai/ollama/openrouter/anthropic/custom.")
136
+
137
+
138
+ def _loads(s):
139
+ import json
140
+
141
+ try:
142
+ return json.loads(s or "{}")
143
+ except json.JSONDecodeError:
144
+ return {}
145
+
146
+
147
+ def _dumps(d):
148
+ import json
149
+
150
+ return json.dumps(d)
151
+
152
+
153
+ def _split_system(messages):
154
+ system = None
155
+ msgs = []
156
+ for m in messages:
157
+ if m["role"] == "system":
158
+ system = m["content"]
159
+ else:
160
+ msgs.append(m)
161
+ return system, msgs
okf_kit/chat/repl.py ADDED
@@ -0,0 +1,65 @@
1
+ """Terminal chat loop for `okf chat`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..registry import resolve_bundle
8
+ from . import agent, retrieval
9
+ from .history import History
10
+ from .providers import make_provider
11
+
12
+
13
+ def run_chat(
14
+ name_or_dir: str,
15
+ *,
16
+ provider: str | None = None,
17
+ model: str | None = None,
18
+ base_url: str | None = None,
19
+ trace: bool = False,
20
+ resume: bool = False,
21
+ show_history: bool = False,
22
+ ) -> int:
23
+ bundle_dir = resolve_bundle(name_or_dir)
24
+ bundle_name = Path(bundle_dir).name
25
+
26
+ if show_history:
27
+ sessions = History.list_sessions(bundle_name)
28
+ if not sessions:
29
+ print("No saved chats for this bundle.")
30
+ for name, turns in sessions:
31
+ print(f"{name} ({turns} turns)")
32
+ return 0
33
+
34
+ prov = make_provider(provider, model, base_url)
35
+ history = History.latest(bundle_name) if resume else History(bundle_name)
36
+ if resume and history is None:
37
+ history = History(bundle_name)
38
+
39
+ if prov is None:
40
+ print("No LLM provider configured — retrieval-only mode.")
41
+ print("For a synthesized answer: --provider ollama (offline) or --provider openai\n")
42
+ else:
43
+ print(f"Chatting with '{bundle_name}' via {provider or 'default'} — Ctrl-D to exit.\n")
44
+
45
+ while True:
46
+ try:
47
+ question = input("you> ").strip()
48
+ except (EOFError, KeyboardInterrupt):
49
+ print()
50
+ return 0
51
+ if not question:
52
+ continue
53
+
54
+ history.append("user", question)
55
+ result = retrieval.answer(bundle_dir, question) if prov is None else agent.ask(
56
+ bundle_dir, question, prov
57
+ )
58
+ print(f"\n{result['answer']}\n")
59
+ if trace and result["steps"]:
60
+ print(" trace: " + " → ".join(f"{s['tool']} {s['path']}" for s in result["steps"]))
61
+ if result["sources"]:
62
+ print(" sources: " + ", ".join(result["sources"]))
63
+ print()
64
+ history.append("assistant", result["answer"],
65
+ meta={"steps": result["steps"], "sources": result["sources"]})
@@ -0,0 +1,35 @@
1
+ """Zero-key fallback: answer from keyword retrieval with citations.
2
+
3
+ When no LLM provider is configured, `okf chat` still does something useful —
4
+ it returns the most relevant concepts with their matching passages quoted and
5
+ cited, clearly labeled as retrieval-only. (Adopted from the deterministic
6
+ `ask` in bushans/okfgen; see docs/ECOSYSTEM.md.)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from ..bundle_nav import search_bundle
14
+
15
+
16
+ def answer(bundle_dir, question: str, *, limit: int = 3) -> dict:
17
+ bundle_dir = Path(bundle_dir)
18
+ hits = search_bundle(bundle_dir, question, limit=limit)
19
+ if not hits:
20
+ return {
21
+ "answer": "No matching content found in this bundle (retrieval-only, no LLM configured).",
22
+ "steps": [{"tool": "search_bundle", "path": question}],
23
+ "sources": [],
24
+ }
25
+ lines = ["Retrieval-only answer (no LLM configured) — most relevant concepts:\n"]
26
+ for i, h in enumerate(hits, 1):
27
+ title = h["title"] or h["path"]
28
+ lines.append(f"{i}. {title} [{h['path']}]")
29
+ lines.append(f" {h['snippet']}\n")
30
+ lines.append("Configure a provider for a synthesized answer: okf chat <bundle> --provider ollama")
31
+ return {
32
+ "answer": "\n".join(lines),
33
+ "steps": [{"tool": "search_bundle", "path": question}],
34
+ "sources": [h["path"] for h in hits],
35
+ }