Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,19 @@ Octop voice
https://github.com/TencentCloud/Octop
Copyright (c) 2026 Octop
Licensed under the MIT License.

Jonathan Ai document search is adapted from Octop's knowledge base. PDF,
DOCX, Markdown, plain text, and HTML are split into passages and stored in
a per-user SQLite file under the user's home directory. Answers cite the
file name and the passage. Local embeddings use fastembed with the ONNX
build of BAAI/bge-small-en-v1.5 from Hugging Face, and each file is checked
against a pinned checksum before it is used. Hosts other than huggingface.co
are refused. When that model is not on the machine, search uses BM25 and
still cites the file and passage. Document text stays on this machine unless
the user selects the OpenAI embedding provider. Jonathan Ai does not vendor
Octop's OCR, spreadsheet parsers, or its download race against other hosts.

Octop knowledge base
https://github.com/TencentCloud/Octop
Copyright (c) 2026 Octop
Licensed under the MIT License.
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ voice = [
"sounddevice>=0.4.6",
"piper-tts>=1.2.0",
]
kb = [
"fastembed>=0.4.2",
"pypdf>=4.2.0",
]

[project.scripts]
clawd = "src.cli:main"
Expand Down
35 changes: 35 additions & 0 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ def main():
clawd cron list List local scheduled jobs
clawd cron run <id> Run one scheduled job now
clawd voice settings Show local speech settings
clawd kb add <path> Index a local PDF, DOCX, MD, TXT, or HTML file
clawd kb search <query> Search local documents and cite the passage
clawd Start interactive REPL
"""
)
Expand Down Expand Up @@ -110,6 +112,16 @@ def main():
voice_parser = subparsers.add_parser('voice', help='Local speech, push-to-talk, and devices')
voice_parser.add_argument('words', nargs='*', help='settings, devices, stt, tts, ptt, wake, say')

kb_parser = subparsers.add_parser('kb', help='Add, list, remove, or search the local document index')
kb_sub = kb_parser.add_subparsers(dest='kb_command')
kb_add = kb_sub.add_parser('add', help='Index a local document')
kb_add.add_argument('path')
kb_sub.add_parser('list', help='List indexed documents')
kb_remove = kb_sub.add_parser('remove', help='Remove one document by id')
kb_remove.add_argument('doc_id')
kb_search = kb_sub.add_parser('search', help='Search indexed documents')
kb_search.add_argument('query', nargs='+')

args = parser.parse_args()

# Handle --version
Expand Down Expand Up @@ -189,6 +201,8 @@ def main():
return handle_cron(args)
elif args.command == 'voice':
return handle_voice(args)
elif args.command == 'kb':
return handle_kb(args)

# Default: start REPL
return start_repl(stream=args.stream)
Expand Down Expand Up @@ -308,6 +322,27 @@ def handle_voice(args) -> int:
return 0


def handle_kb(args) -> int:
"""Add, list, remove, or search documents stored on this machine."""
from src.knowledge.service import format_kb_command

action = getattr(args, "kb_command", None) or "list"
if action == "add":
text = format_kb_command("add " + args.path)
elif action == "remove":
text = format_kb_command("remove " + args.doc_id)
elif action == "search":
text = format_kb_command("search " + " ".join(args.query))
elif action == "list":
text = format_kb_command("list")
else:
text = format_kb_command("")
print(text)
if text.startswith("No document") or text.startswith("document not found"):
return 1
return 0


def start_repl(stream: bool = False):
"""Start interactive REPL."""
from src.config import get_default_provider
Expand Down
2 changes: 2 additions & 0 deletions src/command_system/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
HELP_COMMAND,
CRON_COMMAND,
INIT_COMMAND,
KB_COMMAND,
MEMORY_COMMAND,
VOICE_COMMAND,
SKILLS_COMMAND,
Expand Down Expand Up @@ -94,6 +95,7 @@
"CONTEXT_COMMAND",
"COMPACT_COMMAND",
"CRON_COMMAND",
"KB_COMMAND",
"MEMORY_COMMAND",
"VOICE_COMMAND",
"INIT_COMMAND",
Expand Down
20 changes: 20 additions & 0 deletions src/command_system/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,22 @@ def voice_command_call(args: str, context: CommandContext) -> LocalCommandResult

return LocalCommandResult(type="text", value=format_voice_command(args))


KB_COMMAND = LocalCommand(
name="kb",
description="Add, list, remove, or search local documents",
argument_hint="add <path> | list | remove <id> | search <query>",
supports_non_interactive=True,
)


def kb_command_call(args: str, context: CommandContext) -> LocalCommandResult:
"""Search documents indexed on this machine."""
del context
from ..knowledge.service import format_kb_command

return LocalCommandResult(type="text", value=format_kb_command(args))

INIT_COMMAND = PromptCommand(
name="init",
description="Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation",
Expand Down Expand Up @@ -702,6 +718,8 @@ def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> t
result = cron_command_call(args, context)
elif cmd is VOICE_COMMAND:
result = voice_command_call(args, context)
elif cmd is KB_COMMAND:
result = kb_command_call(args, context)
else:
return False, None, f"Command not implemented for sync execution: {cmd_name}"

Expand All @@ -721,6 +739,7 @@ def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> t
MEMORY_COMMAND.set_call(memory_command_call)
CRON_COMMAND.set_call(cron_command_call)
VOICE_COMMAND.set_call(voice_command_call)
KB_COMMAND.set_call(kb_command_call)


def get_builtin_commands() -> list[Command]:
Expand All @@ -736,6 +755,7 @@ def get_builtin_commands() -> list[Command]:
MEMORY_COMMAND,
CRON_COMMAND,
VOICE_COMMAND,
KB_COMMAND,
INIT_COMMAND,
]

Expand Down
1 change: 1 addition & 0 deletions src/desktop/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,7 @@ def _format_palette(self) -> str:
- `/cron` — list, run, enable, disable, or delete local schedules (`/cron run <id>`)
- Browser tool — local Chrome over CDP. Logins stay in the profile. Submits, purchases, and logins need approval.
- `/voice` — local speech and speakers (`/voice settings`, `/voice say hello`)
- `/kb` — add, list, remove, or search local documents (`/kb search <query>`)

Type a message to run the existing agent loop. Destructive and network tools
prompt for approval before they run.
Expand Down
8 changes: 8 additions & 0 deletions src/desktop/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ def handle_api(self, method: str, path: str, query: dict[str, list[str]], body:
from src.voice.service import push_to_talk_action

return 200, push_to_talk_action(str(body.get("action") or ""))
if path == "/api/kb" and method == "GET":
from src.knowledge.service import knowledge_status

return 200, knowledge_status()
if path == "/api/kb" and method == "POST":
from src.knowledge.service import update_knowledge_settings

return 200, update_knowledge_settings(body)
return 404, {"error": f"unknown route: {method} {path}"}

def serve_forever(self) -> None:
Expand Down
30 changes: 29 additions & 1 deletion src/desktop/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,16 @@ async function sendMessage(path = "/api/chat") {
}
}

async function loadKnowledgeSettings() {
const info = await api("/api/kb");
if ($("kb-backend")) $("kb-backend").value = info.embedding_backend || "local";
if ($("kb-model")) $("kb-model").value = info.model_id || "";
const stay = info.documents_stay_local
? "Documents stay on this machine."
: "Cloud embeddings are selected, so document text is sent when indexing.";
if ($("kb-status")) $("kb-status").textContent = `${stay} ${info.document_count || 0} documents.`;
}

function applyRoute() {
const route = currentRoute();
document.body.dataset.route = route;
Expand All @@ -679,7 +689,10 @@ function applyRoute() {
const showSetup = route === "setup" || (route === "chat" && Boolean(state.status?.needs_setup));
$("setup-modal")?.classList.toggle("hidden", !showSetup);
if (route === "library") refreshLibrary().catch(reportError);
if (route === "settings") loadVoiceSettings().catch(reportError);
if (route === "settings") {
loadVoiceSettings().catch(reportError);
loadKnowledgeSettings().catch(reportError);
}
}

async function refreshLibrary() {
Expand Down Expand Up @@ -980,6 +993,21 @@ function bindUi() {
});
$("open-settings").addEventListener("click", () => go("settings"));
$("settings-close").addEventListener("click", () => go("chat"));
$("kb-save")?.addEventListener("click", guard(async () => {
const saved = await api("/api/kb", {
method: "POST",
body: JSON.stringify({
embedding_backend: $("kb-backend").value,
model_id: $("kb-model").value,
openai_api_key: $("kb-key").value,
}),
});
$("kb-key").value = "";
const stay = saved.documents_stay_local
? "Documents stay on this machine."
: "Cloud embeddings are selected, so document text is sent when indexing.";
$("kb-status").textContent = `${stay} ${saved.document_count || 0} documents.`;
}));
$("settings-save").addEventListener("click", async () => {
try {
const provider = $("settings-provider").value;
Expand Down
17 changes: 17 additions & 0 deletions src/desktop/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,23 @@ <h3>Voice</h3>
<input id="voice-wake-phrase" type="text" value="jonathan" />
</label>
<p id="voice-note" class="muted"></p>
<h3>Knowledge base</h3>
<p id="kb-status" class="muted">Documents stay on this machine unless a cloud embedding provider is explicitly chosen.</p>
<label>Embedding
<select id="kb-backend">
<option value="local">Local ONNX (default)</option>
<option value="openai">OpenAI embeddings</option>
</select>
</label>
<label>Local model
<input id="kb-model" type="text" readonly />
</label>
<label>Cloud embedding key
<input id="kb-key" type="password" autocomplete="off" placeholder="Used only when OpenAI embeddings are selected" />
</label>
<div class="row">
<button id="kb-save" class="ghost" type="button">Save knowledge settings</button>
</div>
<p class="muted">Source and updates: <code>https://github.com/GoDeskio/Clawd-Code</code>. Default local tree: <code>~/Jonathan/Jonathan-Ai</code>.</p>
<div class="row end">
<button id="settings-close" class="ghost">Close</button>
Expand Down
22 changes: 22 additions & 0 deletions src/knowledge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Local document knowledge base for Jonathan Ai.

Documents are parsed, chunked, and stored in a per-user SQLite file on this
machine. Search uses a local ONNX embedding model when its Hugging Face
checksums match, and BM25 otherwise. Document text is sent to a cloud
embedding provider only when that provider is explicitly selected.

The chunking and citation shape follow Octop's knowledge base. Jonathan Ai
does not vendor Octop's service, OCR, or mirror download race.

Source: https://github.com/TencentCloud/Octop
Copyright (c) 2026 Octop
SPDX-License-Identifier: MIT
"""

from src.knowledge.service import format_kb_command, knowledge_status, update_knowledge_settings

__all__ = [
"format_kb_command",
"knowledge_status",
"update_knowledge_settings",
]
49 changes: 49 additions & 0 deletions src/knowledge/bm25.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Stdlib BM25 ranking used when local embeddings are not available."""

from __future__ import annotations

import math
import re
from collections import Counter

_TOKEN = re.compile(r"[a-z0-9]+")


def tokenize(text: str) -> list[str]:
return _TOKEN.findall(text.lower())


def bm25_scores(
query: str,
documents: list[str],
*,
k1: float = 1.5,
b: float = 0.75,
) -> list[float]:
"""Score each document against ``query``. Higher is a closer match."""
if not documents:
return []
query_terms = tokenize(query)
if not query_terms:
return [0.0] * len(documents)
tokenized = [tokenize(document) for document in documents]
lengths = [len(tokens) for tokens in tokenized]
average = sum(lengths) / len(lengths) if lengths else 0.0
doc_count = len(tokenized)
document_frequency: Counter[str] = Counter()
for tokens in tokenized:
document_frequency.update(set(tokens))
scores: list[float] = []
for tokens, length in zip(tokenized, lengths):
frequency = Counter(tokens)
score = 0.0
for term in query_terms:
seen = document_frequency.get(term, 0)
if seen == 0 or term not in frequency:
continue
idf = math.log(1.0 + (doc_count - seen + 0.5) / (seen + 0.5))
term_frequency = frequency[term]
denominator = term_frequency + k1 * (1.0 - b + b * (length / average if average else 0.0))
score += idf * (term_frequency * (k1 + 1.0)) / denominator
scores.append(score)
return scores
29 changes: 29 additions & 0 deletions src/knowledge/chunk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Overlapping character windows for knowledge documents.

Adapted from Octop's knowledge chunker (MIT). The window size and overlap
match that splitter so a passage stays a stable citation target.

Source: https://github.com/TencentCloud/Octop
Copyright (c) 2026 Octop
SPDX-License-Identifier: MIT
"""

from __future__ import annotations


def chunk_text(text: str, *, size: int = 800, overlap: int = 120) -> list[str]:
"""Split non-empty text into overlapping character windows."""
if size <= 0:
raise ValueError("size must be positive")
if overlap < 0 or overlap >= size:
raise ValueError("overlap must be non-negative and smaller than size")
content = text.strip()
if not content:
return []
step = size - overlap
chunks: list[str] = []
for start in range(0, len(content), step):
chunks.append(content[start : start + size])
if start + size >= len(content):
break
return chunks
Loading
Loading