local-knowledge 0.1.0__tar.gz
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.
- local_knowledge-0.1.0/PKG-INFO +45 -0
- local_knowledge-0.1.0/README.md +17 -0
- local_knowledge-0.1.0/pyproject.toml +53 -0
- local_knowledge-0.1.0/setup.cfg +4 -0
- local_knowledge-0.1.0/src/local_knowledge/__init__.py +1 -0
- local_knowledge-0.1.0/src/local_knowledge/cli_local.py +92 -0
- local_knowledge-0.1.0/src/local_knowledge/crawl_local.py +199 -0
- local_knowledge-0.1.0/src/local_knowledge/server_local.py +298 -0
- local_knowledge-0.1.0/src/local_knowledge/settings_local.py +67 -0
- local_knowledge-0.1.0/src/local_knowledge.egg-info/PKG-INFO +45 -0
- local_knowledge-0.1.0/src/local_knowledge.egg-info/SOURCES.txt +17 -0
- local_knowledge-0.1.0/src/local_knowledge.egg-info/dependency_links.txt +1 -0
- local_knowledge-0.1.0/src/local_knowledge.egg-info/entry_points.txt +2 -0
- local_knowledge-0.1.0/src/local_knowledge.egg-info/requires.txt +15 -0
- local_knowledge-0.1.0/src/local_knowledge.egg-info/top_level.txt +1 -0
- local_knowledge-0.1.0/tests/test_agent_doc_consistency.py +31 -0
- local_knowledge-0.1.0/tests/test_crawl_local.py +118 -0
- local_knowledge-0.1.0/tests/test_extract_files.py +132 -0
- local_knowledge-0.1.0/tests/test_server_flow_local.py +92 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: local-knowledge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local file/folder indexer + grounded-Q&A MCP service — same engine as confluence-knowledge, no credentials needed
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/nkunal1992/agents
|
|
7
|
+
Project-URL: Documentation, https://github.com/nkunal1992/agents/blob/main/docs/local-knowledge.md
|
|
8
|
+
Project-URL: Issues, https://github.com/nkunal1992/agents/issues
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: confluence-knowledge>=0.4.0
|
|
16
|
+
Requires-Dist: python-docx>=1.1
|
|
17
|
+
Requires-Dist: pypdf>=4.0
|
|
18
|
+
Requires-Dist: python-pptx>=1.0
|
|
19
|
+
Requires-Dist: openpyxl>=3.1
|
|
20
|
+
Requires-Dist: mcp<2,>=1.6
|
|
21
|
+
Requires-Dist: pip-system-certs>=5.0
|
|
22
|
+
Provides-Extra: models
|
|
23
|
+
Requires-Dist: confluence-knowledge[models]>=0.4.0; extra == "models"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# local-knowledge
|
|
30
|
+
|
|
31
|
+
Local file/folder indexer + grounded-Q&A MCP service — the same engine as
|
|
32
|
+
[`confluence-knowledge`](../confluence-knowledge), pointed at the filesystem
|
|
33
|
+
instead of Confluence. No credentials, no HTTP.
|
|
34
|
+
|
|
35
|
+
- `local_knowledge/crawl_local.py` — durable filesystem BFS (secret-file
|
|
36
|
+
exclusions, symlink-escape guards, checkpointed resume)
|
|
37
|
+
- `local_knowledge/server_local.py` — the `local-knowledge/*` MCP tools
|
|
38
|
+
- `local_knowledge/cli_local.py` — `local-knowledge` CLI (no `setup`; there's
|
|
39
|
+
nothing to authenticate)
|
|
40
|
+
|
|
41
|
+
Shares `confluence_knowledge`'s index, extraction (`extract` +
|
|
42
|
+
`extract_files`), retrieval, registration and auth plumbing.
|
|
43
|
+
|
|
44
|
+
See `docs/local-knowledge.md` in the repo root for the full guide and
|
|
45
|
+
`bundle/local-README.md` for the store-listing copy.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# local-knowledge
|
|
2
|
+
|
|
3
|
+
Local file/folder indexer + grounded-Q&A MCP service — the same engine as
|
|
4
|
+
[`confluence-knowledge`](../confluence-knowledge), pointed at the filesystem
|
|
5
|
+
instead of Confluence. No credentials, no HTTP.
|
|
6
|
+
|
|
7
|
+
- `local_knowledge/crawl_local.py` — durable filesystem BFS (secret-file
|
|
8
|
+
exclusions, symlink-escape guards, checkpointed resume)
|
|
9
|
+
- `local_knowledge/server_local.py` — the `local-knowledge/*` MCP tools
|
|
10
|
+
- `local_knowledge/cli_local.py` — `local-knowledge` CLI (no `setup`; there's
|
|
11
|
+
nothing to authenticate)
|
|
12
|
+
|
|
13
|
+
Shares `confluence_knowledge`'s index, extraction (`extract` +
|
|
14
|
+
`extract_files`), retrieval, registration and auth plumbing.
|
|
15
|
+
|
|
16
|
+
See `docs/local-knowledge.md` in the repo root for the full guide and
|
|
17
|
+
`bundle/local-README.md` for the store-listing copy.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "local-knowledge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local file/folder indexer + grounded-Q&A MCP service — same engine as confluence-knowledge, no credentials needed"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"confluence-knowledge>=0.4.0",
|
|
20
|
+
"python-docx>=1.1",
|
|
21
|
+
"pypdf>=4.0",
|
|
22
|
+
"python-pptx>=1.0",
|
|
23
|
+
"openpyxl>=3.1",
|
|
24
|
+
"mcp>=1.6,<2",
|
|
25
|
+
"pip-system-certs>=5.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
models = [
|
|
30
|
+
"confluence-knowledge[models]>=0.4.0",
|
|
31
|
+
]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0",
|
|
34
|
+
"pytest-cov>=5.0",
|
|
35
|
+
"ruff>=0.6",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Repository = "https://github.com/nkunal1992/agents"
|
|
40
|
+
Documentation = "https://github.com/nkunal1992/agents/blob/main/docs/local-knowledge.md"
|
|
41
|
+
Issues = "https://github.com/nkunal1992/agents/issues"
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
local-knowledge = "local_knowledge.cli_local:main"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 110
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""local-knowledge — local file/folder indexing + grounded Q&A MCP service."""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""local-knowledge CLI: doctor / prepare-models / register / serve / clean.
|
|
2
|
+
|
|
3
|
+
No `setup` — there are no credentials to collect. connect() takes the path.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from confluence_knowledge.retrieval import prepare_models
|
|
15
|
+
from confluence_knowledge.settings import SetupError
|
|
16
|
+
|
|
17
|
+
from .settings_local import (
|
|
18
|
+
APP_NAME,
|
|
19
|
+
INDEX_DIR_NAME,
|
|
20
|
+
home,
|
|
21
|
+
workdir,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
WORKDIR_ENV = "LOCAL_KNOWLEDGE_WORKDIR"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _doctor() -> int:
|
|
28
|
+
from .server_local import status
|
|
29
|
+
s = status()
|
|
30
|
+
print(json.dumps(s, indent=2))
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> None:
|
|
35
|
+
ap = argparse.ArgumentParser(prog=APP_NAME)
|
|
36
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
37
|
+
sub.add_parser("doctor", help="verify install + models")
|
|
38
|
+
sub.add_parser("prepare-models", help="download the pinned local models")
|
|
39
|
+
reg = sub.add_parser(
|
|
40
|
+
"register",
|
|
41
|
+
help="write this server into the IDE's MCP config (no manual paste)")
|
|
42
|
+
reg.add_argument("--ide", default="auto",
|
|
43
|
+
choices=["auto", "all", "vscode", "jetbrains"])
|
|
44
|
+
reg.add_argument("--workspace", type=Path, default=None)
|
|
45
|
+
reg.add_argument("--remove", action="store_true")
|
|
46
|
+
sub.add_parser("serve", help="run the stdio MCP server")
|
|
47
|
+
clean = sub.add_parser("clean", help="delete the local index")
|
|
48
|
+
clean.add_argument("--yes", action="store_true")
|
|
49
|
+
args = ap.parse_args()
|
|
50
|
+
|
|
51
|
+
if args.cmd == "doctor":
|
|
52
|
+
sys.exit(_doctor())
|
|
53
|
+
if args.cmd == "prepare-models":
|
|
54
|
+
for line in prepare_models(home()):
|
|
55
|
+
print(line)
|
|
56
|
+
return
|
|
57
|
+
if args.cmd == "register":
|
|
58
|
+
from confluence_knowledge.registration import register
|
|
59
|
+
try:
|
|
60
|
+
wd = (Path(os.environ[WORKDIR_ENV]).expanduser().resolve()
|
|
61
|
+
if os.environ.get(WORKDIR_ENV) else None)
|
|
62
|
+
results = register(ide=args.ide, workspace=args.workspace,
|
|
63
|
+
workdir=wd, remove=args.remove,
|
|
64
|
+
server_id=APP_NAME,
|
|
65
|
+
module="local_knowledge.server_local",
|
|
66
|
+
env_var=WORKDIR_ENV)
|
|
67
|
+
except SetupError as e:
|
|
68
|
+
print(f"error: {e}", file=sys.stderr)
|
|
69
|
+
sys.exit(2)
|
|
70
|
+
for path, action in results:
|
|
71
|
+
print(f"{action}: {path}")
|
|
72
|
+
if not args.remove:
|
|
73
|
+
print("\nReload your IDE so Copilot picks up the server, "
|
|
74
|
+
"then re-ask the agent.")
|
|
75
|
+
return
|
|
76
|
+
if args.cmd == "serve":
|
|
77
|
+
from .server_local import main as serve
|
|
78
|
+
serve()
|
|
79
|
+
return
|
|
80
|
+
if args.cmd == "clean":
|
|
81
|
+
import shutil
|
|
82
|
+
|
|
83
|
+
target = workdir() / INDEX_DIR_NAME
|
|
84
|
+
if target.exists() and (args.yes or input(
|
|
85
|
+
f"delete {target}? [y/N] ").strip().lower() == "y"):
|
|
86
|
+
shutil.rmtree(target)
|
|
87
|
+
print("index deleted")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Durable, resumable filesystem crawl — one path (file or folder) → index.
|
|
2
|
+
|
|
3
|
+
Same durable-queue/checkpoint contract as the Confluence crawler: progress
|
|
4
|
+
survives interruption, the background worker runs to completion, and only a
|
|
5
|
+
completed crawl publishes (a partial refresh never clobbers a good snapshot).
|
|
6
|
+
Reads only — never writes, follows no symlinks outside the root.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from confluence_knowledge.extract_files import extract_file_chunks, is_secret_name
|
|
17
|
+
from confluence_knowledge.index import Index
|
|
18
|
+
from confluence_knowledge.settings import SetupError
|
|
19
|
+
|
|
20
|
+
# dirs never worth indexing — noise or actively harmful to read
|
|
21
|
+
SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv",
|
|
22
|
+
"venv", ".idea", ".vscode", "target", "dist", "build", ".tox"}
|
|
23
|
+
MAX_FILE_BYTES = 25 * 1024 * 1024 # 25MB — bigger files get a warning, not a crash
|
|
24
|
+
MAX_FILES = 20_000 # guard against "pointed at $HOME" accidents
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_root(path: str) -> Path:
|
|
28
|
+
"""Validate + canonicalize a user-supplied path (file or folder)."""
|
|
29
|
+
p = Path(path).expanduser()
|
|
30
|
+
if not p.exists():
|
|
31
|
+
raise SetupError(f"path does not exist: {path}")
|
|
32
|
+
return p.resolve()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LocalCrawler:
|
|
36
|
+
def __init__(self, index: Index, root: Path, embedder=None):
|
|
37
|
+
self.index = index
|
|
38
|
+
self.root = root # realpath — everything indexed must live under it
|
|
39
|
+
self.embedder = embedder
|
|
40
|
+
|
|
41
|
+
def start(self) -> str:
|
|
42
|
+
job_id = uuid.uuid4().hex[:12]
|
|
43
|
+
self.index.abandon_running_jobs()
|
|
44
|
+
self.index.create_job(job_id, str(self.root))
|
|
45
|
+
first = f"dir:{self.root}" if self.root.is_dir() else f"file:{self.root}"
|
|
46
|
+
self.index.save_job(job_id, "running", [first], [])
|
|
47
|
+
return job_id
|
|
48
|
+
|
|
49
|
+
def _rel(self, path: Path) -> str:
|
|
50
|
+
try:
|
|
51
|
+
return str(path.relative_to(self.root.parent)).replace("\\", "/")
|
|
52
|
+
except ValueError:
|
|
53
|
+
return path.name
|
|
54
|
+
|
|
55
|
+
def _children(self, d: Path) -> tuple[list[str], list[str]]:
|
|
56
|
+
"""(new queue entries, warnings) — guarded, secret-aware listing."""
|
|
57
|
+
out, warns = [], []
|
|
58
|
+
try:
|
|
59
|
+
entries = sorted(d.iterdir())
|
|
60
|
+
except (PermissionError, OSError) as e:
|
|
61
|
+
return out, [f"{self._rel(d)}: unreadable — {e}"]
|
|
62
|
+
for e in entries:
|
|
63
|
+
if e.is_symlink():
|
|
64
|
+
# resolve and refuse escape — a symlink inside the root that
|
|
65
|
+
# points outside is disclosed, never followed
|
|
66
|
+
try:
|
|
67
|
+
rp = e.resolve()
|
|
68
|
+
except OSError:
|
|
69
|
+
warns.append(f"{self._rel(e)}: broken symlink — skipped")
|
|
70
|
+
continue
|
|
71
|
+
if self.root.is_dir() and not rp.is_relative_to(self.root):
|
|
72
|
+
warns.append(f"{self._rel(e)}: symlink escapes the root — skipped")
|
|
73
|
+
continue
|
|
74
|
+
e = rp
|
|
75
|
+
if e.is_dir():
|
|
76
|
+
if e.name in SKIP_DIRS or e.name.startswith(".git"):
|
|
77
|
+
continue
|
|
78
|
+
out.append(f"dir:{e}")
|
|
79
|
+
elif e.is_file():
|
|
80
|
+
if is_secret_name(e.name):
|
|
81
|
+
warns.append(f"{self._rel(e)}: looks like a secret — skipped")
|
|
82
|
+
continue
|
|
83
|
+
out.append(f"file:{e}")
|
|
84
|
+
return out, warns
|
|
85
|
+
|
|
86
|
+
def _page(self, f: Path) -> tuple[dict, list]:
|
|
87
|
+
try:
|
|
88
|
+
st = f.stat()
|
|
89
|
+
if st.st_size > MAX_FILE_BYTES:
|
|
90
|
+
return ({"page_id": self._rel(f), "title": f.name,
|
|
91
|
+
"url": f"file://{f}", "version": f"{st.st_mtime_ns}:{st.st_size}",
|
|
92
|
+
"content_hash": "",
|
|
93
|
+
"warnings": [f"file too large ({st.st_size} bytes) — skipped"]},
|
|
94
|
+
[])
|
|
95
|
+
data = f.read_bytes()
|
|
96
|
+
except OSError as e:
|
|
97
|
+
raise SetupError(f"{self._rel(f)}: unreadable — {e}") from e
|
|
98
|
+
chunks, warns = extract_file_chunks(f.name, data)
|
|
99
|
+
content_hash = hashlib.sha256(data).hexdigest()
|
|
100
|
+
return ({"page_id": self._rel(f), "title": f.name,
|
|
101
|
+
"url": f"file://{f}",
|
|
102
|
+
"version": f"{st.st_mtime_ns}:{st.st_size}",
|
|
103
|
+
"content_hash": content_hash,
|
|
104
|
+
"warnings": warns},
|
|
105
|
+
chunks)
|
|
106
|
+
|
|
107
|
+
def _checkpoint(self, job_id, queue, errors, seen, levels, warns) -> None:
|
|
108
|
+
with self.index.lock, self.index.db:
|
|
109
|
+
self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
|
|
110
|
+
(f"seen:{job_id}", json.dumps(sorted(seen))))
|
|
111
|
+
self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
|
|
112
|
+
(f"levels:{job_id}", json.dumps(levels)))
|
|
113
|
+
self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
|
|
114
|
+
(f"warns:{job_id}", json.dumps(warns)))
|
|
115
|
+
self.index.db.execute(
|
|
116
|
+
"UPDATE jobs SET queue=?, errors=? WHERE job_id=?",
|
|
117
|
+
(json.dumps(queue), json.dumps(errors), job_id))
|
|
118
|
+
|
|
119
|
+
def step(self, job_id: str) -> dict:
|
|
120
|
+
"""Process the durable queue to completion — files are cheap; the
|
|
121
|
+
time-budget concern that drove Confluence's per-step cap doesn't apply
|
|
122
|
+
to filesystem reads, and the worker calls this unbounded anyway."""
|
|
123
|
+
job = self.index.job(job_id)
|
|
124
|
+
if job is None:
|
|
125
|
+
raise SetupError(f"unknown job {job_id}")
|
|
126
|
+
if job["state"] != "running":
|
|
127
|
+
return {"job_id": job_id, "state": job["state"],
|
|
128
|
+
"remaining": len(job["queue"]), "errors": job["errors"]}
|
|
129
|
+
|
|
130
|
+
queue: list[str] = job["queue"]
|
|
131
|
+
errors: list[str] = job["errors"]
|
|
132
|
+
seen_raw = self.index.meta_get(f"seen:{job_id}")
|
|
133
|
+
seen: set[str] = set(json.loads(seen_raw)) if seen_raw else set()
|
|
134
|
+
levels_raw = self.index.meta_get(f"levels:{job_id}")
|
|
135
|
+
levels: dict[str, int] = (json.loads(levels_raw) if levels_raw else {})
|
|
136
|
+
warns_raw = self.index.meta_get(f"warns:{job_id}")
|
|
137
|
+
# deliberate skips (secrets, escaping symlinks, unreadable dirs) are
|
|
138
|
+
# disclosures — reported to the agent but not coverage-degrading errors
|
|
139
|
+
warns: list[str] = json.loads(warns_raw) if warns_raw else []
|
|
140
|
+
visited_dirs: set[str] = set() # guards symlink/dir-graph cycles
|
|
141
|
+
|
|
142
|
+
processed = 0
|
|
143
|
+
while queue:
|
|
144
|
+
if len(seen) >= MAX_FILES:
|
|
145
|
+
errors.append(f"file cap reached ({MAX_FILES}) — remaining skipped")
|
|
146
|
+
queue.clear()
|
|
147
|
+
break
|
|
148
|
+
if processed:
|
|
149
|
+
self._checkpoint(job_id, queue, errors, seen, levels, warns)
|
|
150
|
+
entry = queue.pop(0)
|
|
151
|
+
# entries are "dir:<path>" / "file:<path>" — split on the PREFIX,
|
|
152
|
+
# not ':' (Windows paths contain their own drive colon)
|
|
153
|
+
is_dir_entry = entry.startswith("dir:")
|
|
154
|
+
path_s = entry[4:] if is_dir_entry else entry[5:]
|
|
155
|
+
path = Path(path_s)
|
|
156
|
+
if path_s in seen:
|
|
157
|
+
continue
|
|
158
|
+
processed += 1
|
|
159
|
+
if is_dir_entry:
|
|
160
|
+
real = str(path.resolve())
|
|
161
|
+
if real in visited_dirs:
|
|
162
|
+
continue # symlink/loop — directories aren't pages
|
|
163
|
+
visited_dirs.add(real)
|
|
164
|
+
kids, warns2 = self._children(path)
|
|
165
|
+
warns.extend(warns2)
|
|
166
|
+
queue.extend(k for k in kids
|
|
167
|
+
if (k[4:] if k.startswith("dir:") else k[5:]) not in seen)
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
page, chunks = self._page(path)
|
|
171
|
+
except SetupError as e:
|
|
172
|
+
errors.append(str(e))
|
|
173
|
+
continue
|
|
174
|
+
rel = page["page_id"]
|
|
175
|
+
if rel in seen:
|
|
176
|
+
continue
|
|
177
|
+
for c in chunks:
|
|
178
|
+
c.chunk_id = f"{rel}:{page['content_hash'][:8]}:{c.ordinal}"
|
|
179
|
+
vecs = None
|
|
180
|
+
if self.embedder is not None and chunks:
|
|
181
|
+
vecs = self.embedder.embed(
|
|
182
|
+
[f"{c.heading_path}\n{c.text}" for c in chunks])
|
|
183
|
+
self.index.upsert_page({
|
|
184
|
+
"page_id": rel, "parent_id": None, "title": page["title"],
|
|
185
|
+
"url": page["url"], "level": levels.get(rel, path_s.count("/")),
|
|
186
|
+
"version": page["version"], "content_hash": page["content_hash"],
|
|
187
|
+
"warnings": page["warnings"]}, chunks, vecs)
|
|
188
|
+
seen.add(rel)
|
|
189
|
+
|
|
190
|
+
# persist final seen/levels/warns too — the in-loop checkpoint runs
|
|
191
|
+
# before each item is processed, so the last item would undercount
|
|
192
|
+
self._checkpoint(job_id, queue, errors, seen, levels, warns)
|
|
193
|
+
self.index.publish(seen)
|
|
194
|
+
self.index.save_job(job_id, "done" if not queue else "running",
|
|
195
|
+
queue, errors)
|
|
196
|
+
return {"job_id": job_id,
|
|
197
|
+
"state": "done" if not queue else "running",
|
|
198
|
+
"remaining": len(queue), "pages_seen": len(seen),
|
|
199
|
+
"errors": errors, "warnings": warns}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""stdio MCP server for local-knowledge — the only surface the agent reaches.
|
|
2
|
+
|
|
3
|
+
Same tool surface as confluence-knowledge minus the credential machinery:
|
|
4
|
+
there is nothing to authenticate. Scope handles are opaque paths bound to the
|
|
5
|
+
canonical realpath partition.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import socket
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
from confluence_knowledge.client import AccessError, SecurityException
|
|
15
|
+
from confluence_knowledge.index import Index
|
|
16
|
+
from confluence_knowledge.retrieval import (
|
|
17
|
+
LocalEmbedder,
|
|
18
|
+
LocalReranker,
|
|
19
|
+
check_citations,
|
|
20
|
+
retrieve,
|
|
21
|
+
)
|
|
22
|
+
from confluence_knowledge.settings import SetupError
|
|
23
|
+
from mcp.server.fastmcp import FastMCP
|
|
24
|
+
|
|
25
|
+
from .crawl_local import LocalCrawler, resolve_root
|
|
26
|
+
from .settings_local import APP_NAME, LocalProfile, home, index_root
|
|
27
|
+
|
|
28
|
+
mcp = FastMCP("local-knowledge")
|
|
29
|
+
|
|
30
|
+
_scopes: dict[str, dict] = {}
|
|
31
|
+
_evidence_sets: dict[str, list[dict]] = {}
|
|
32
|
+
_indexes: dict[str, Index] = {}
|
|
33
|
+
_EMBEDDER = None
|
|
34
|
+
_RERANKER = None
|
|
35
|
+
_model_lock = threading.Lock()
|
|
36
|
+
_models_ready = threading.Event()
|
|
37
|
+
_warm_lock = threading.Lock()
|
|
38
|
+
_warm_started = False
|
|
39
|
+
|
|
40
|
+
LOADING_NOTE = "loading local models — call this tool again"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _index_for(scope_key: str) -> Index:
|
|
44
|
+
idx = _indexes.get(scope_key)
|
|
45
|
+
if idx is None:
|
|
46
|
+
idx = _indexes[scope_key] = Index(index_root() / f"{scope_key}.db")
|
|
47
|
+
return idx
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _embedder():
|
|
51
|
+
global _EMBEDDER
|
|
52
|
+
with _model_lock:
|
|
53
|
+
if _EMBEDDER is None:
|
|
54
|
+
_EMBEDDER = LocalEmbedder(home(), APP_NAME)
|
|
55
|
+
return _EMBEDDER
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _reranker():
|
|
59
|
+
global _RERANKER
|
|
60
|
+
with _model_lock:
|
|
61
|
+
if _RERANKER is None:
|
|
62
|
+
_RERANKER = LocalReranker(home(), APP_NAME)
|
|
63
|
+
return _RERANKER
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _warm_models() -> None:
|
|
67
|
+
try:
|
|
68
|
+
_embedder()
|
|
69
|
+
_reranker()
|
|
70
|
+
except SetupError:
|
|
71
|
+
pass
|
|
72
|
+
finally:
|
|
73
|
+
_models_ready.set()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _start_warmup() -> None:
|
|
77
|
+
global _warm_started
|
|
78
|
+
with _warm_lock:
|
|
79
|
+
if _warm_started:
|
|
80
|
+
return
|
|
81
|
+
_warm_started = True
|
|
82
|
+
threading.Thread(target=_warm_models, name="model-warmup", daemon=True).start()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _models_loaded(wait: float) -> bool:
|
|
86
|
+
_start_warmup()
|
|
87
|
+
return _models_ready.wait(wait)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _scope(scope_handle: str) -> dict:
|
|
91
|
+
s = _scopes.get(scope_handle)
|
|
92
|
+
if s is None:
|
|
93
|
+
parts = scope_handle.split(":", 2)
|
|
94
|
+
if len(parts) == 3 and parts[0] == "scope" and \
|
|
95
|
+
connect(parts[2])["scope_handle"] == scope_handle:
|
|
96
|
+
return _scopes[scope_handle]
|
|
97
|
+
raise SetupError("unknown scope — call connect first")
|
|
98
|
+
return s
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@mcp.tool()
|
|
102
|
+
def status() -> dict:
|
|
103
|
+
"""Report setup readiness — no credentials exist for this agent."""
|
|
104
|
+
models = home() / "models"
|
|
105
|
+
try:
|
|
106
|
+
profile = LocalProfile.load()
|
|
107
|
+
cfg = {"root": profile.root_path, "device": profile.device}
|
|
108
|
+
except SetupError as e:
|
|
109
|
+
cfg = {"error": str(e)}
|
|
110
|
+
return {
|
|
111
|
+
"configured": "error" not in cfg,
|
|
112
|
+
"profile": cfg,
|
|
113
|
+
"embedding_model_ready": (models / "all-MiniLM-L6-v2").exists(),
|
|
114
|
+
"reranker_model_ready": (models / "ms-marco-MiniLM-L6-v2").exists(),
|
|
115
|
+
"models_loaded": _models_ready.is_set() and _EMBEDDER is not None,
|
|
116
|
+
"index_root": str(index_root()),
|
|
117
|
+
"live_scopes": sorted(_scopes),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@mcp.tool()
|
|
122
|
+
def connect(root: str) -> dict:
|
|
123
|
+
"""Bind a scope to a file or folder path. No credentials needed — the
|
|
124
|
+
filesystem itself is the ACL (we can only read what the user can)."""
|
|
125
|
+
path = resolve_root(root)
|
|
126
|
+
profile = LocalProfile(str(path), socket.gethostname())
|
|
127
|
+
profile.save() # remember last-used root for status/doctor
|
|
128
|
+
scope_key = profile.scope_key()
|
|
129
|
+
idx = _index_for(scope_key)
|
|
130
|
+
st = idx.stats()
|
|
131
|
+
handle = f"scope:{scope_key}:{path}"
|
|
132
|
+
_scopes[handle] = {"key": scope_key, "root": path}
|
|
133
|
+
_start_warmup()
|
|
134
|
+
return {
|
|
135
|
+
"scope_handle": handle,
|
|
136
|
+
"root": str(path),
|
|
137
|
+
"kind": "folder" if path.is_dir() else "file",
|
|
138
|
+
"cached": st["pages"] > 0,
|
|
139
|
+
"cached_pages": st["pages"],
|
|
140
|
+
"cached_as_of": st["as_of"],
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_workers: dict[str, threading.Thread] = {}
|
|
145
|
+
_worker_errors: dict[str, str] = {}
|
|
146
|
+
_workers_lock = threading.Lock()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _worker_loop(s: dict, job_id: str) -> None:
|
|
150
|
+
idx = _index_for(s["key"])
|
|
151
|
+
try:
|
|
152
|
+
_models_ready.wait()
|
|
153
|
+
c = LocalCrawler(idx, s["root"], embedder=_embedder())
|
|
154
|
+
r = c.step(job_id)
|
|
155
|
+
while r["state"] == "running":
|
|
156
|
+
r = c.step(job_id)
|
|
157
|
+
except Exception as e: # noqa: BLE001 — surfaced via job state
|
|
158
|
+
err = f"{type(e).__name__}: {e}"
|
|
159
|
+
_worker_errors[f"{s['key']}:{job_id}"] = err
|
|
160
|
+
job = idx.job(job_id)
|
|
161
|
+
if job and job["state"] == "running":
|
|
162
|
+
idx.save_job(job_id, "error", job["queue"], job["errors"] + [err])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _ensure_worker(s: dict, job_id: str) -> None:
|
|
166
|
+
key = f"{s['key']}:{job_id}"
|
|
167
|
+
with _workers_lock:
|
|
168
|
+
t = _workers.get(key)
|
|
169
|
+
if t is not None and t.is_alive():
|
|
170
|
+
return
|
|
171
|
+
_workers[key] = threading.Thread(
|
|
172
|
+
target=_worker_loop, args=(s, job_id),
|
|
173
|
+
name=f"crawl-{job_id}", daemon=True)
|
|
174
|
+
_workers[key].start()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@mcp.tool()
|
|
178
|
+
def start_crawl(scope_handle: str, refresh: bool = False) -> dict:
|
|
179
|
+
"""Begin indexing the root (file or folder, recursive) in the background."""
|
|
180
|
+
s = _scope(scope_handle)
|
|
181
|
+
idx = _index_for(s["key"])
|
|
182
|
+
c = LocalCrawler(idx, s["root"])
|
|
183
|
+
job_id = c.start()
|
|
184
|
+
_ensure_worker(s, job_id)
|
|
185
|
+
return {"job_id": job_id, "state": "running",
|
|
186
|
+
"note": "crawl runs in the background — call crawl_step to poll"}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@mcp.tool()
|
|
190
|
+
def crawl_step(scope_handle: str, job_id: str) -> dict:
|
|
191
|
+
"""Read crawl progress instantly; resumes a dead worker automatically."""
|
|
192
|
+
s = _scope(scope_handle)
|
|
193
|
+
idx = _index_for(s["key"])
|
|
194
|
+
job = idx.job(job_id)
|
|
195
|
+
if job is None:
|
|
196
|
+
raise AccessError(f"unknown job {job_id}")
|
|
197
|
+
def progress() -> dict:
|
|
198
|
+
p = idx.job_progress(job_id)
|
|
199
|
+
import json
|
|
200
|
+
warns = idx.meta_get(f"warns:{job_id}")
|
|
201
|
+
if warns:
|
|
202
|
+
p["warnings"] = json.loads(warns)
|
|
203
|
+
return p
|
|
204
|
+
|
|
205
|
+
if job["state"] == "running":
|
|
206
|
+
if not _models_ready.is_set():
|
|
207
|
+
_start_warmup()
|
|
208
|
+
note = LOADING_NOTE
|
|
209
|
+
else:
|
|
210
|
+
note = None
|
|
211
|
+
_ensure_worker(s, job_id)
|
|
212
|
+
p = progress()
|
|
213
|
+
if note:
|
|
214
|
+
p["note"] = note
|
|
215
|
+
return p
|
|
216
|
+
p = progress()
|
|
217
|
+
if p["state"] == "done":
|
|
218
|
+
p["note"] = "no further calls needed — the index is published"
|
|
219
|
+
return p
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _coverage(idx: Index) -> str:
|
|
223
|
+
job = idx.latest_job()
|
|
224
|
+
if job is None or job["state"] == "abandoned":
|
|
225
|
+
return "unknown"
|
|
226
|
+
if job["state"] == "running":
|
|
227
|
+
return "refresh in progress — answering from the previous snapshot"
|
|
228
|
+
return "partial" if job["errors"] else "complete"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@mcp.tool()
|
|
232
|
+
def search(scope_handle: str, question: str) -> dict:
|
|
233
|
+
"""Hybrid retrieve + local rerank; returns bounded, cited evidence."""
|
|
234
|
+
s = _scope(scope_handle)
|
|
235
|
+
idx = _index_for(s["key"])
|
|
236
|
+
st = idx.stats()
|
|
237
|
+
if st["pages"] == 0:
|
|
238
|
+
job = idx.latest_job()
|
|
239
|
+
if job and job["state"] == "running":
|
|
240
|
+
return {"evidence": [], "coverage": "crawling",
|
|
241
|
+
"note": f"crawl {job['job_id']} still running — keep calling crawl_step "
|
|
242
|
+
"until state is done, then search"}
|
|
243
|
+
return {"evidence": [], "coverage": "empty",
|
|
244
|
+
"note": "no files indexed — run a crawl first"}
|
|
245
|
+
if not _models_loaded(10.0):
|
|
246
|
+
return {"evidence": [], "coverage": "loading", "note": LOADING_NOTE}
|
|
247
|
+
ev = retrieve(idx, question, _embedder(), _reranker())
|
|
248
|
+
set_id = f"ev:{len(_evidence_sets)}:{s['key'][:8]}"
|
|
249
|
+
_evidence_sets[set_id] = ev
|
|
250
|
+
return {
|
|
251
|
+
"evidence_set": set_id,
|
|
252
|
+
"coverage": _coverage(idx),
|
|
253
|
+
"as_of": st["as_of"],
|
|
254
|
+
"evidence": [
|
|
255
|
+
{"chunk_id": e["chunk_id"], "page_title": e["page_title"],
|
|
256
|
+
"page_url": e["page_url"], "heading": e["heading"],
|
|
257
|
+
"score": e.get("rerank_score"), "text": e["text"][:2000]}
|
|
258
|
+
for e in ev
|
|
259
|
+
],
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@mcp.tool()
|
|
264
|
+
def get_evidence(scope_handle: str, chunk_ids: list[str]) -> dict:
|
|
265
|
+
"""Return full text of previously returned chunks — same scope only."""
|
|
266
|
+
s = _scope(scope_handle)
|
|
267
|
+
idx = _index_for(s["key"])
|
|
268
|
+
all_chunks = {c["chunk_id"]: c for c in idx.active_chunks()}
|
|
269
|
+
out = []
|
|
270
|
+
for cid in chunk_ids[:16]:
|
|
271
|
+
c = all_chunks.get(cid)
|
|
272
|
+
if c is None:
|
|
273
|
+
raise SecurityException(f"chunk {cid} not in this scope's index")
|
|
274
|
+
p = idx.active_pages().get(c["page_id"], {})
|
|
275
|
+
out.append({"chunk_id": cid, "text": c["text"],
|
|
276
|
+
"page_title": p.get("title", ""), "page_url": p.get("url", "")})
|
|
277
|
+
return {"chunks": out}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@mcp.tool()
|
|
281
|
+
def verify_citations(scope_handle: str, evidence_set: str,
|
|
282
|
+
mappings: list[dict]) -> dict:
|
|
283
|
+
_scope(scope_handle)
|
|
284
|
+
ev = _evidence_sets.get(evidence_set)
|
|
285
|
+
if ev is None or not evidence_set.endswith(_scopes[scope_handle]["key"][:8]):
|
|
286
|
+
raise SecurityException(
|
|
287
|
+
"unknown or cross-scope evidence set — run search again and cite its evidence_set")
|
|
288
|
+
return check_citations(ev, mappings)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main() -> None:
|
|
292
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
293
|
+
_start_warmup()
|
|
294
|
+
mcp.run()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Filesystem layout for local-knowledge — ~/.local-knowledge, LOCAL_KNOWLEDGE_*.
|
|
2
|
+
|
|
3
|
+
The engine is confluence_knowledge's settings machinery parametrized for the
|
|
4
|
+
local connector; no credentials exist (connect reads only paths).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from confluence_knowledge.settings import (
|
|
16
|
+
SetupError,
|
|
17
|
+
default_home,
|
|
18
|
+
resolve_workdir,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
APP_NAME = "local-knowledge"
|
|
22
|
+
INDEX_DIR_NAME = ".local_knowledge_index"
|
|
23
|
+
PROFILE_NAME = "profile.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def home() -> Path:
|
|
27
|
+
return default_home("LOCAL_KNOWLEDGE_HOME", ".local-knowledge")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def workdir() -> Path:
|
|
31
|
+
return resolve_workdir("LOCAL_KNOWLEDGE_WORKDIR",
|
|
32
|
+
"LOCAL_KNOWLEDGE_HOME", ".local-knowledge")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def index_root() -> Path:
|
|
36
|
+
return workdir() / INDEX_DIR_NAME
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class LocalProfile:
|
|
41
|
+
"""A non-secret connect profile — just the root path that was indexed."""
|
|
42
|
+
|
|
43
|
+
root_path: str # canonical realpath of the file/folder
|
|
44
|
+
device: str # machine the path lives on (informational)
|
|
45
|
+
|
|
46
|
+
def scope_key(self) -> str:
|
|
47
|
+
raw = f"{self.root_path}|{self.device}"
|
|
48
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:24]
|
|
49
|
+
|
|
50
|
+
def save(self) -> Path:
|
|
51
|
+
p = home() / PROFILE_NAME
|
|
52
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
tmp = p.with_suffix(".tmp")
|
|
54
|
+
tmp.write_text(json.dumps({
|
|
55
|
+
"root_path": self.root_path, "device": self.device,
|
|
56
|
+
}, indent=2))
|
|
57
|
+
os.replace(tmp, p)
|
|
58
|
+
return p
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def load() -> LocalProfile:
|
|
62
|
+
p = home() / PROFILE_NAME
|
|
63
|
+
if not p.exists():
|
|
64
|
+
raise SetupError("no local profile — run `local-knowledge connect` "
|
|
65
|
+
"with a path via the agent, or `local-knowledge doctor`")
|
|
66
|
+
d = json.loads(p.read_text())
|
|
67
|
+
return LocalProfile(d["root_path"], d.get("device", "local"))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: local-knowledge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local file/folder indexer + grounded-Q&A MCP service — same engine as confluence-knowledge, no credentials needed
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/nkunal1992/agents
|
|
7
|
+
Project-URL: Documentation, https://github.com/nkunal1992/agents/blob/main/docs/local-knowledge.md
|
|
8
|
+
Project-URL: Issues, https://github.com/nkunal1992/agents/issues
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: confluence-knowledge>=0.4.0
|
|
16
|
+
Requires-Dist: python-docx>=1.1
|
|
17
|
+
Requires-Dist: pypdf>=4.0
|
|
18
|
+
Requires-Dist: python-pptx>=1.0
|
|
19
|
+
Requires-Dist: openpyxl>=3.1
|
|
20
|
+
Requires-Dist: mcp<2,>=1.6
|
|
21
|
+
Requires-Dist: pip-system-certs>=5.0
|
|
22
|
+
Provides-Extra: models
|
|
23
|
+
Requires-Dist: confluence-knowledge[models]>=0.4.0; extra == "models"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# local-knowledge
|
|
30
|
+
|
|
31
|
+
Local file/folder indexer + grounded-Q&A MCP service — the same engine as
|
|
32
|
+
[`confluence-knowledge`](../confluence-knowledge), pointed at the filesystem
|
|
33
|
+
instead of Confluence. No credentials, no HTTP.
|
|
34
|
+
|
|
35
|
+
- `local_knowledge/crawl_local.py` — durable filesystem BFS (secret-file
|
|
36
|
+
exclusions, symlink-escape guards, checkpointed resume)
|
|
37
|
+
- `local_knowledge/server_local.py` — the `local-knowledge/*` MCP tools
|
|
38
|
+
- `local_knowledge/cli_local.py` — `local-knowledge` CLI (no `setup`; there's
|
|
39
|
+
nothing to authenticate)
|
|
40
|
+
|
|
41
|
+
Shares `confluence_knowledge`'s index, extraction (`extract` +
|
|
42
|
+
`extract_files`), retrieval, registration and auth plumbing.
|
|
43
|
+
|
|
44
|
+
See `docs/local-knowledge.md` in the repo root for the full guide and
|
|
45
|
+
`bundle/local-README.md` for the store-listing copy.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/local_knowledge/__init__.py
|
|
4
|
+
src/local_knowledge/cli_local.py
|
|
5
|
+
src/local_knowledge/crawl_local.py
|
|
6
|
+
src/local_knowledge/server_local.py
|
|
7
|
+
src/local_knowledge/settings_local.py
|
|
8
|
+
src/local_knowledge.egg-info/PKG-INFO
|
|
9
|
+
src/local_knowledge.egg-info/SOURCES.txt
|
|
10
|
+
src/local_knowledge.egg-info/dependency_links.txt
|
|
11
|
+
src/local_knowledge.egg-info/entry_points.txt
|
|
12
|
+
src/local_knowledge.egg-info/requires.txt
|
|
13
|
+
src/local_knowledge.egg-info/top_level.txt
|
|
14
|
+
tests/test_agent_doc_consistency.py
|
|
15
|
+
tests/test_crawl_local.py
|
|
16
|
+
tests/test_extract_files.py
|
|
17
|
+
tests/test_server_flow_local.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
local_knowledge
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The local-knowledge agent file must only reference CLI commands that exist."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
REPO = Path(__file__).resolve().parents[3]
|
|
7
|
+
AGENT = REPO / "agents" / "local-knowledge.agent.md"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_agent_references_only_real_subcommands():
|
|
11
|
+
if not AGENT.exists():
|
|
12
|
+
import pytest
|
|
13
|
+
pytest.skip("agent file not in this checkout layout")
|
|
14
|
+
src = (Path(__file__).parents[1] / "src" /
|
|
15
|
+
"local_knowledge" / "cli_local.py").read_text()
|
|
16
|
+
real = set(re.findall(r'add_parser\(\s*"([a-z-]+)"', src))
|
|
17
|
+
doc = AGENT.read_text()
|
|
18
|
+
# prose like "the local-knowledge service isn't responding" is not a
|
|
19
|
+
# subcommand — the excluded words below are nouns, not commands
|
|
20
|
+
referenced = set(re.findall(r"local-knowledge[` ]+([a-z-]+)", doc)) - \
|
|
21
|
+
{"doctor", "-", "exe", "service", "agent", "knowledge", "bundle"}
|
|
22
|
+
for cmd in referenced:
|
|
23
|
+
assert cmd in real, \
|
|
24
|
+
f"agent references `local-knowledge {cmd}` — not a real subcommand"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_agent_references_repo_files_that_exist():
|
|
28
|
+
doc = AGENT.read_text()
|
|
29
|
+
for m in re.findall(r"prompts/([\w.-]+)", doc):
|
|
30
|
+
assert (REPO / "prompts" / m).exists(), \
|
|
31
|
+
f"agent references missing prompts/{m}"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""LocalCrawler: file/folder scope, secret exclusion, symlink guards, resume."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from confluence_knowledge.index import Index
|
|
8
|
+
from confluence_knowledge.settings import SetupError
|
|
9
|
+
|
|
10
|
+
from local_knowledge.crawl_local import LocalCrawler, resolve_root
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def tree(tmp_path):
|
|
15
|
+
root = tmp_path / "docs"
|
|
16
|
+
(root / "sub").mkdir(parents=True)
|
|
17
|
+
(root / "guide.md").write_text("the gateway restart procedure")
|
|
18
|
+
(root / "sub" / "deep.txt").write_text("nested content here")
|
|
19
|
+
(root / "data.csv").write_text("k,v\na,1\n")
|
|
20
|
+
(root / ".env").write_text("SECRET=x")
|
|
21
|
+
(root / "id_rsa").write_text("PRIVATE KEY")
|
|
22
|
+
(root / ".git").mkdir()
|
|
23
|
+
(root / ".git" / "config").write_text("should be skipped")
|
|
24
|
+
(root / "weird.bin").write_bytes(b"\x00\x01\x02")
|
|
25
|
+
return root
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def idx(tmp_path):
|
|
30
|
+
return Index(tmp_path / "i.db")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_all(c: LocalCrawler, job: str) -> dict:
|
|
34
|
+
r = c.step(job)
|
|
35
|
+
return r
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_crawl_folder(tree, idx):
|
|
39
|
+
c = LocalCrawler(idx, tree)
|
|
40
|
+
r = run_all(c, c.start())
|
|
41
|
+
assert r["state"] == "done"
|
|
42
|
+
pages = idx.active_pages()
|
|
43
|
+
titles = {p["title"] for p in pages.values()}
|
|
44
|
+
assert {"guide.md", "deep.txt", "data.csv"} <= titles
|
|
45
|
+
# secret files + .git contents never indexed
|
|
46
|
+
assert "SECRET=x" not in " ".join(p["title"] for p in pages.values())
|
|
47
|
+
assert ".env" not in titles and "id_rsa" not in titles
|
|
48
|
+
assert "config" not in titles
|
|
49
|
+
# excluded + unsupported disclosed — secrets land in job warnings (not
|
|
50
|
+
# coverage-degrading errors), parse issues in that page's warnings
|
|
51
|
+
assert "secret" in " ".join(r["warnings"])
|
|
52
|
+
warn_pages = [w for p in idx.db.execute(
|
|
53
|
+
"SELECT warnings FROM pages").fetchall() for w in json.loads(p[0])]
|
|
54
|
+
assert any("unsupported" in w for w in warn_pages)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_crawl_single_file(tmp_path, idx):
|
|
58
|
+
f = tmp_path / "one.txt"
|
|
59
|
+
f.write_text("solo file content")
|
|
60
|
+
c = LocalCrawler(idx, f)
|
|
61
|
+
r = run_all(c, c.start())
|
|
62
|
+
assert r["state"] == "done" and r["pages_seen"] == 1
|
|
63
|
+
assert idx.active_chunks()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_secret_file_directly_is_indexed(tmp_path, idx):
|
|
67
|
+
"""An explicit single-file root is indexed even if it looks like a secret
|
|
68
|
+
— the user pointed at it directly; disclosure only applies to listings."""
|
|
69
|
+
f = tmp_path / ".env"
|
|
70
|
+
f.write_text("USER_SECRET=abc")
|
|
71
|
+
c = LocalCrawler(idx, f)
|
|
72
|
+
r = run_all(c, c.start())
|
|
73
|
+
assert r["pages_seen"] == 1 # indexed — explicit user choice
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_symlink_escape_disclosed(tmp_path, idx):
|
|
77
|
+
root = tmp_path / "docs"
|
|
78
|
+
root.mkdir()
|
|
79
|
+
(root / "a.txt").write_text("inside")
|
|
80
|
+
outside = tmp_path / "outside.txt"
|
|
81
|
+
outside.write_text("escaped content")
|
|
82
|
+
link = root / "escape.txt"
|
|
83
|
+
link.symlink_to(outside)
|
|
84
|
+
c = LocalCrawler(idx, root)
|
|
85
|
+
r = run_all(c, c.start())
|
|
86
|
+
assert "escape" in " ".join(r["warnings"])
|
|
87
|
+
titles = {p["title"] for p in idx.active_pages().values()}
|
|
88
|
+
assert "escape.txt" not in titles and "a.txt" in titles
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_dir_cycle_terminates(tmp_path, idx):
|
|
92
|
+
root = tmp_path / "docs"
|
|
93
|
+
(root / "sub").mkdir(parents=True)
|
|
94
|
+
(root / "a.txt").write_text("x")
|
|
95
|
+
(root / "sub" / "loop").symlink_to(root, target_is_directory=True)
|
|
96
|
+
c = LocalCrawler(idx, root)
|
|
97
|
+
r = run_all(c, c.start())
|
|
98
|
+
assert r["state"] == "done" # terminates — no infinite queue
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_resolve_root(tmp_path):
|
|
102
|
+
assert resolve_root(str(tmp_path)) == tmp_path.resolve()
|
|
103
|
+
with pytest.raises(SetupError):
|
|
104
|
+
resolve_root(str(tmp_path / "nope"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_publish_is_atomic(tree, idx, tmp_path):
|
|
108
|
+
"""A crawl that later sees an empty folder publishes only the new set."""
|
|
109
|
+
c = LocalCrawler(idx, tree)
|
|
110
|
+
run_all(c, c.start())
|
|
111
|
+
assert idx.stats()["pages"] >= 3
|
|
112
|
+
# delete a file and re-crawl — the index drops it from active
|
|
113
|
+
(tree / "sub" / "deep.txt").unlink()
|
|
114
|
+
c2 = LocalCrawler(idx, tree)
|
|
115
|
+
r2 = run_all(c2, c2.start())
|
|
116
|
+
assert r2["state"] == "done"
|
|
117
|
+
titles = {p["title"] for p in idx.active_pages().values()}
|
|
118
|
+
assert "deep.txt" not in titles
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""extract_files: real bytes → chunks per format; warnings, never crashes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from confluence_knowledge.extract_files import (
|
|
8
|
+
extract_file_chunks,
|
|
9
|
+
is_secret_name,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
pytest.importorskip("docx")
|
|
13
|
+
pytest.importorskip("openpyxl")
|
|
14
|
+
pytest.importorskip("pptx")
|
|
15
|
+
pytest.importorskip("pypdf")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _docx_bytes(text: str = "The deploy runbook says restart the gateway first.") -> bytes:
|
|
19
|
+
import docx
|
|
20
|
+
d = docx.Document()
|
|
21
|
+
d.add_heading("Deploy Runbook", level=1)
|
|
22
|
+
d.add_paragraph(text)
|
|
23
|
+
buf = io.BytesIO()
|
|
24
|
+
d.save(buf)
|
|
25
|
+
return buf.getvalue()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _xlsx_bytes() -> bytes:
|
|
29
|
+
import openpyxl
|
|
30
|
+
wb = openpyxl.Workbook()
|
|
31
|
+
ws = wb.active
|
|
32
|
+
ws.title = "Owners"
|
|
33
|
+
ws.append(["service", "owner"])
|
|
34
|
+
ws.append(["gateway", "narayan"])
|
|
35
|
+
buf = io.BytesIO()
|
|
36
|
+
wb.save(buf)
|
|
37
|
+
return buf.getvalue()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _pptx_bytes() -> bytes:
|
|
41
|
+
from pptx import Presentation
|
|
42
|
+
prs = Presentation()
|
|
43
|
+
slide = prs.slides.add_slide(prs.slide_layouts[1]) # title + content
|
|
44
|
+
slide.shapes.title.text = "Migration Plan"
|
|
45
|
+
body = slide.placeholders[1]
|
|
46
|
+
body.text = "Phase one: move the database."
|
|
47
|
+
buf = io.BytesIO()
|
|
48
|
+
prs.save(buf)
|
|
49
|
+
return buf.getvalue()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_txt_chunks():
|
|
53
|
+
chunks, warns = extract_file_chunks("notes.txt", b"hello world " * 100)
|
|
54
|
+
assert chunks and not warns
|
|
55
|
+
assert "hello world" in chunks[0].text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_md_chunks():
|
|
59
|
+
chunks, _ = extract_file_chunks("r.md", b"# Title\n\nsome content")
|
|
60
|
+
assert chunks and "content" in chunks[0].text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_csv_chunks():
|
|
64
|
+
chunks, _ = extract_file_chunks("t.csv", b"a,b\n1,2\n3,4\n")
|
|
65
|
+
assert chunks[0].kind == "table" and "row 1: 1 | 2" in chunks[0].text
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_html_chunks():
|
|
69
|
+
chunks, _ = extract_file_chunks("p.html", b"<h1>H</h1><p>body text</p>")
|
|
70
|
+
assert chunks and "body text" in chunks[0].text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_docx_chunks():
|
|
74
|
+
chunks, warns = extract_file_chunks("runbook.docx", _docx_bytes())
|
|
75
|
+
assert chunks and not warns
|
|
76
|
+
assert "gateway" in " ".join(c.text for c in chunks)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_docx_heading_path():
|
|
80
|
+
chunks, _ = extract_file_chunks("r.docx", _docx_bytes())
|
|
81
|
+
assert any("Deploy Runbook" in c.heading_path for c in chunks)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_xlsx_chunks():
|
|
85
|
+
chunks, _ = extract_file_chunks("o.xlsx", _xlsx_bytes())
|
|
86
|
+
text = " ".join(c.text for c in chunks)
|
|
87
|
+
assert "gateway" in text and "narayan" in text
|
|
88
|
+
assert any("Sheet: Owners" in c.heading_path for c in chunks)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_pptx_chunks():
|
|
92
|
+
chunks, _ = extract_file_chunks("p.pptx", _pptx_bytes())
|
|
93
|
+
text = " ".join(c.text for c in chunks)
|
|
94
|
+
assert "Migration Plan" in text and "database" in text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_unknown_type_warns():
|
|
98
|
+
chunks, warns = extract_file_chunks("x.bin", b"\x00\x01")
|
|
99
|
+
assert not chunks and warns and "unsupported" in warns[0]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_corrupt_office_warns_not_crashes():
|
|
103
|
+
chunks, warns = extract_file_chunks("bad.docx", b"not a zip")
|
|
104
|
+
assert not chunks and warns
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_secret_names():
|
|
108
|
+
assert is_secret_name(".env") and is_secret_name("id_rsa")
|
|
109
|
+
assert is_secret_name("cert.pem") and is_secret_name("credentials.json")
|
|
110
|
+
assert is_secret_name(".env.production")
|
|
111
|
+
assert not is_secret_name("deploy.docx") and not is_secret_name("notes.txt")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_pdf_minimal():
|
|
115
|
+
"""A real (hand-built) one-page PDF with extractable text."""
|
|
116
|
+
pdf = b"""%PDF-1.4
|
|
117
|
+
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
|
118
|
+
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
|
119
|
+
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
|
|
120
|
+
4 0 obj<</Length 70>>stream
|
|
121
|
+
BT /F1 24 Tf 100 700 Td (deploy steps live here) Tj ET
|
|
122
|
+
endstream
|
|
123
|
+
endobj
|
|
124
|
+
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
|
125
|
+
trailer<</Root 1 0 R>>
|
|
126
|
+
"""
|
|
127
|
+
chunks, warns = extract_file_chunks("doc.pdf", pdf)
|
|
128
|
+
# pypdf extracts the text; if a version can't parse the hand-rolled xref,
|
|
129
|
+
# it must warn — never crash
|
|
130
|
+
assert chunks or warns
|
|
131
|
+
if chunks:
|
|
132
|
+
assert "deploy steps" in " ".join(c.text for c in chunks)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""local-knowledge server flow: connect → crawl → search → verify citations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from local_knowledge import server_local
|
|
8
|
+
from local_knowledge.settings_local import LocalProfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeEmbedder:
|
|
12
|
+
def __init__(self, _home=None, _cli=None):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
def embed(self, texts):
|
|
16
|
+
return np.random.rand(len(texts), 8).astype(np.float32)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeReranker:
|
|
20
|
+
def __init__(self, _home=None, _cli=None):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
def score(self, query, docs):
|
|
24
|
+
return np.arange(len(docs), dtype=np.float32)[::-1]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(autouse=True)
|
|
28
|
+
def isolated(tmp_path, monkeypatch):
|
|
29
|
+
monkeypatch.setenv("LOCAL_KNOWLEDGE_HOME", str(tmp_path / "home"))
|
|
30
|
+
monkeypatch.setenv("LOCAL_KNOWLEDGE_WORKDIR", str(tmp_path / "work"))
|
|
31
|
+
monkeypatch.setattr(server_local, "_EMBEDDER", FakeEmbedder())
|
|
32
|
+
monkeypatch.setattr(server_local, "_RERANKER", FakeReranker())
|
|
33
|
+
server_local._models_ready.set()
|
|
34
|
+
yield
|
|
35
|
+
server_local._scopes.clear()
|
|
36
|
+
server_local._indexes.clear()
|
|
37
|
+
server_local._evidence_sets.clear()
|
|
38
|
+
server_local._models_ready.clear()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_full_flow(tmp_path):
|
|
42
|
+
root = tmp_path / "docs"
|
|
43
|
+
root.mkdir()
|
|
44
|
+
(root / "guide.md").write_text("restart the gateway before the deploy")
|
|
45
|
+
(root / "data.csv").write_text("service,owner\ngateway,ops\n")
|
|
46
|
+
(root / ".env").write_text("SECRET=x")
|
|
47
|
+
|
|
48
|
+
c = server_local.connect(str(root))
|
|
49
|
+
assert c["kind"] == "folder"
|
|
50
|
+
job = server_local.start_crawl(c["scope_handle"])
|
|
51
|
+
r = server_local.crawl_step(c["scope_handle"], job["job_id"])
|
|
52
|
+
# worker may still be running — poll until done
|
|
53
|
+
while r["state"] == "running":
|
|
54
|
+
r = server_local.crawl_step(c["scope_handle"], job["job_id"])
|
|
55
|
+
assert r["pages_seen"] >= 2
|
|
56
|
+
|
|
57
|
+
res = server_local.search(c["scope_handle"], "who owns the gateway?")
|
|
58
|
+
assert res["coverage"] == "complete"
|
|
59
|
+
assert res["evidence"]
|
|
60
|
+
first = res["evidence"][0]
|
|
61
|
+
assert first["page_url"].startswith("file://")
|
|
62
|
+
|
|
63
|
+
v = server_local.verify_citations(
|
|
64
|
+
c["scope_handle"], res["evidence_set"],
|
|
65
|
+
[{"claim": "gateway is owned by ops",
|
|
66
|
+
"quotes": [res["evidence"][0]["text"][:60]]}])
|
|
67
|
+
assert "results" in v or isinstance(v, dict)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_search_while_crawling(tmp_path):
|
|
71
|
+
root = tmp_path / "docs"
|
|
72
|
+
root.mkdir()
|
|
73
|
+
(root / "a.txt").write_text("x")
|
|
74
|
+
c = server_local.connect(str(root))
|
|
75
|
+
# search before any crawl → empty + told to crawl
|
|
76
|
+
res = server_local.search(c["scope_handle"], "anything")
|
|
77
|
+
assert res["coverage"] == "empty"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_unknown_scope():
|
|
81
|
+
from confluence_knowledge.settings import SetupError
|
|
82
|
+
with pytest.raises(SetupError):
|
|
83
|
+
server_local.search("scope:bad:handle", "q")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_profile_saved(tmp_path):
|
|
87
|
+
root = tmp_path / "docs"
|
|
88
|
+
root.mkdir()
|
|
89
|
+
(root / "a.txt").write_text("x")
|
|
90
|
+
server_local.connect(str(root))
|
|
91
|
+
p = LocalProfile.load()
|
|
92
|
+
assert p.root_path == str(root.resolve())
|