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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ local-knowledge = local_knowledge.cli_local:main
@@ -0,0 +1,15 @@
1
+ confluence-knowledge>=0.4.0
2
+ python-docx>=1.1
3
+ pypdf>=4.0
4
+ python-pptx>=1.0
5
+ openpyxl>=3.1
6
+ mcp<2,>=1.6
7
+ pip-system-certs>=5.0
8
+
9
+ [dev]
10
+ pytest>=8.0
11
+ pytest-cov>=5.0
12
+ ruff>=0.6
13
+
14
+ [models]
15
+ confluence-knowledge[models]>=0.4.0
@@ -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())