confluence-knowledge 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """confluence-knowledge: local GET-only Confluence subtree index + grounded Q&A."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,83 @@
1
+ """Credential storage: OS keyring → env var → protected file (explicit choice).
2
+
3
+ Tokens are never accepted via chat, MCP tool arguments, or command-line flags —
4
+ the setup wizard prompts with getpass in the user's own terminal.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import stat
12
+ from pathlib import Path
13
+
14
+ from .settings import APP_NAME, AUTH_FILE_NAME, ensure_gitignore, reject_unsafe_path
15
+
16
+ SERVICE = APP_NAME
17
+ ENV_TOKEN = "CONF_KNOWLEDGE_TOKEN"
18
+ ENV_EMAIL = "CONF_KNOWLEDGE_EMAIL"
19
+
20
+
21
+ def _keyring_store(ref: str, secret: str) -> bool:
22
+ try:
23
+ import keyring
24
+ from keyring.backends.fail import Keyring as FailBackend
25
+ if isinstance(keyring.get_keyring(), FailBackend):
26
+ return False
27
+ keyring.set_password(SERVICE, ref, secret)
28
+ return True
29
+ except Exception: # noqa: BLE001 — keyring backends fail in many ways; fallback is intentional
30
+ return False
31
+
32
+
33
+ def _keyring_get(ref: str) -> str | None:
34
+ try:
35
+ import keyring
36
+ return keyring.get_password(SERVICE, ref)
37
+ except Exception: # noqa: BLE001 — unavailable keyring → try next provider
38
+ return None
39
+
40
+
41
+ def store_credential(workdir: Path, home: Path, ref: str, token: str) -> str:
42
+ """Persist token: keyring preferred, protected file as explicit fallback.
43
+
44
+ Returns a human description of where it went (never the token).
45
+ """
46
+ if _keyring_store(ref, token):
47
+ return "OS credential store"
48
+ # file fallback — gitignore BEFORE writing
49
+ ensure_gitignore(workdir, [AUTH_FILE_NAME, ".conf_knowledge_index/"])
50
+ p = reject_unsafe_path(workdir / AUTH_FILE_NAME)
51
+ data = json.loads(p.read_text()) if p.exists() else {}
52
+ data[ref] = token
53
+ tmp = p.with_suffix(".tmp")
54
+ tmp.write_text(json.dumps(data))
55
+ os.replace(tmp, p)
56
+ if os.name != "nt":
57
+ os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
58
+ return f"protected file {p} (chmod 600)"
59
+
60
+
61
+ def get_credential(workdir: Path, ref: str) -> str | None:
62
+ """Lookup order: env var (CI/scripts) → keyring → protected file."""
63
+ t = os.environ.get(ENV_TOKEN)
64
+ if t:
65
+ return t
66
+ t = _keyring_get(ref)
67
+ if t:
68
+ return t
69
+ p = workdir / AUTH_FILE_NAME
70
+ if p.exists():
71
+ data = json.loads(p.read_text())
72
+ return data.get(ref)
73
+ p2 = Path.home() / ".confluence-knowledge" / AUTH_FILE_NAME
74
+ if p2.exists():
75
+ return json.loads(p2.read_text()).get(ref)
76
+ return None
77
+
78
+
79
+ def redact(text: str, secrets: list[str]) -> str:
80
+ for s in secrets:
81
+ if s:
82
+ text = text.replace(s, "***")
83
+ return text
@@ -0,0 +1,180 @@
1
+ """confluence-knowledge CLI: setup / doctor / prepare-models / serve / clean.
2
+
3
+ The setup wizard prompts for tokens via getpass in the user's own terminal —
4
+ tokens never travel through chat or command-line arguments.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import getpass
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from .auth import store_credential
17
+ from .client import AccessError, make_client
18
+ from .retrieval import prepare_models
19
+ from .settings import (
20
+ AUTH_FILE_NAME,
21
+ INDEX_DIR_NAME,
22
+ SetupError,
23
+ SiteProfile,
24
+ default_home,
25
+ ensure_gitignore,
26
+ resolve_workdir,
27
+ )
28
+
29
+
30
+ def _wizard() -> int:
31
+ print("confluence-knowledge setup\n")
32
+ env = ""
33
+ while env not in ("1", "2"):
34
+ env = input("Confluence environment — [1] Cloud (atlassian.net) "
35
+ "or [2] Data Center (corporate URL)? ").strip()
36
+ env = "cloud" if env == "1" else "datacenter"
37
+
38
+ if env == "cloud":
39
+ print("\nCreate a token: sign in to https://id.atlassian.com → "
40
+ "Security → Create and manage API tokens → Create API token "
41
+ "(choose Confluence read scopes if scoped tokens are offered).")
42
+ site = input("\nSite URL (e.g. https://acme.atlassian.net): ").strip()
43
+ email = input("Atlassian account email: ").strip()
44
+ else:
45
+ print("\nCreate a token: Confluence → your profile picture → Settings → "
46
+ "Personal Access Tokens → Create token (read access is enough).")
47
+ site = input("\nSite URL incl. context path (e.g. https://conf.acme.corp/confluence): ").strip()
48
+ email = None
49
+
50
+ try:
51
+ base = SiteProfile.canonical_base(env, site)
52
+ except SetupError as e:
53
+ print(f"error: {e}", file=sys.stderr)
54
+ return 2
55
+
56
+ workdir = resolve_workdir()
57
+ workdir.mkdir(parents=True, exist_ok=True)
58
+ home = default_home()
59
+ # gitignore protections BEFORE any secret/cache write
60
+ try:
61
+ ensure_gitignore(workdir, [AUTH_FILE_NAME, ".conf_knowledge_index/"])
62
+ except SetupError as e:
63
+ print(f"error: {e}", file=sys.stderr)
64
+ return 2
65
+
66
+ ref = f"{env}:{base}:{email or '-'}"
67
+ token = getpass.getpass("Token (input hidden): ")
68
+ if not token:
69
+ print("error: empty token", file=sys.stderr)
70
+ return 2
71
+
72
+ where = store_credential(workdir, home, ref, token)
73
+ SiteProfile(env, base, email, ref).save(home)
74
+
75
+ # heartbeat
76
+ try:
77
+ c = make_client(SiteProfile(env, base, email, ref), token)
78
+ ep = "wiki/rest/api/user/current" if env == "cloud" else "rest/api/user/current"
79
+ who = c.get(ep).json()
80
+ print(f"\nconnected as: {who.get('displayName') or who.get('username') or 'OK'}")
81
+ except Exception as e: # noqa: BLE001 — any connect failure (auth/network/TLS) exits non-zero
82
+ print(f"\nconnection check failed: {e}", file=sys.stderr)
83
+ return 3
84
+
85
+ print(f"credential stored: {where}")
86
+ print(f"profile: {home / 'profile.json'}")
87
+ print("\nNext: `confluence-knowledge prepare-models` to fetch the local "
88
+ "models (Apache-2.0), then point your IDE's MCP config at:\n"
89
+ f" command: {sys.executable}\n"
90
+ f" args: [\"-m\", \"confluence_knowledge.server\"]\n"
91
+ f" env: CONF_KNOWLEDGE_WORKDIR={workdir}")
92
+ return 0
93
+
94
+
95
+ def _doctor() -> int:
96
+ from .server import status
97
+ try:
98
+ s = status()
99
+ print(json.dumps(s, indent=2))
100
+ if s.get("configured"):
101
+ from .server import _client_for # internal but fine in CLI
102
+ profile = SiteProfile.load(default_home())
103
+ try:
104
+ c = _client_for(profile)
105
+ ep = ("wiki/rest/api/user/current" if profile.env == "cloud"
106
+ else "rest/api/user/current")
107
+ who = c.get(ep).json()
108
+ print("heartbeat: OK —",
109
+ who.get("displayName") or who.get("username"))
110
+ except (AccessError, SetupError) as e:
111
+ print(f"heartbeat: FAILED — {e}", file=sys.stderr)
112
+ return 3
113
+ return 0
114
+ except SetupError as e:
115
+ print(f"setup error: {e}", file=sys.stderr)
116
+ return 2
117
+
118
+
119
+ def main() -> None:
120
+ ap = argparse.ArgumentParser(prog="confluence-knowledge")
121
+ sub = ap.add_subparsers(dest="cmd", required=True)
122
+ sub.add_parser("setup", help="guided credential + config setup")
123
+ sub.add_parser("doctor", help="verify config, credentials, models")
124
+ sub.add_parser("prepare-models", help="download the pinned local models")
125
+ reg = sub.add_parser(
126
+ "register",
127
+ help="write this server into the IDE's MCP config (no manual paste)")
128
+ reg.add_argument("--ide", default="auto",
129
+ choices=["auto", "all", "vscode", "jetbrains"],
130
+ help="which config files to write (default: all that apply)")
131
+ reg.add_argument("--workspace", type=Path, default=None,
132
+ help="also write <workspace>/.vscode/mcp.json")
133
+ reg.add_argument("--remove", action="store_true",
134
+ help="remove our entry instead of adding it")
135
+ sub.add_parser("serve", help="run the stdio MCP server")
136
+ clean = sub.add_parser("clean", help="delete the local index for this repo")
137
+ clean.add_argument("--yes", action="store_true")
138
+ args = ap.parse_args()
139
+
140
+ if args.cmd == "setup":
141
+ sys.exit(_wizard())
142
+ if args.cmd == "doctor":
143
+ sys.exit(_doctor())
144
+ if args.cmd == "prepare-models":
145
+ for line in prepare_models(default_home()):
146
+ print(line)
147
+ return
148
+ if args.cmd == "register":
149
+ from .registration import register
150
+ try:
151
+ wd = (Path(os.environ["CONF_KNOWLEDGE_WORKDIR"]).expanduser().resolve()
152
+ if os.environ.get("CONF_KNOWLEDGE_WORKDIR") else None)
153
+ results = register(ide=args.ide, workspace=args.workspace,
154
+ workdir=wd, remove=args.remove)
155
+ except SetupError as e:
156
+ print(f"error: {e}", file=sys.stderr)
157
+ sys.exit(2)
158
+ for path, action in results:
159
+ print(f"{action}: {path}")
160
+ if not args.remove:
161
+ print("\nReload your IDE so Copilot picks up the server, "
162
+ "then re-ask the agent.")
163
+ return
164
+ if args.cmd == "serve":
165
+ from .server import main as serve
166
+ serve()
167
+ return
168
+ if args.cmd == "clean":
169
+ import shutil
170
+
171
+ target = resolve_workdir() / INDEX_DIR_NAME
172
+ if target.exists() and (args.yes or input(
173
+ f"delete {target}? [y/N] ").strip().lower() == "y"):
174
+ shutil.rmtree(target)
175
+ print("index deleted")
176
+ return
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()
@@ -0,0 +1,152 @@
1
+ """GET-only Confluence REST client.
2
+
3
+ Every transport call passes through request(): a non-GET method is rejected
4
+ with SecurityException before any network I/O, and paths are validated against
5
+ a per-adapter endpoint allowlist. No convenience method can bypass this.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from urllib.parse import urljoin, urlparse
13
+
14
+ import httpx
15
+
16
+
17
+ class SecurityException(RuntimeError):
18
+ """Raised when a write/delete/mutation HTTP operation is attempted."""
19
+
20
+
21
+ class AccessError(RuntimeError):
22
+ """Authentication, permission, or reachability failure (actionable)."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class EndpointRule:
27
+ pattern: re.Pattern # matched against the API path
28
+ purpose: str
29
+
30
+
31
+ class ConfluenceClient:
32
+ """GET-only client bound to one site base URL and one route allowlist."""
33
+
34
+ def __init__(
35
+ self,
36
+ base_url: str,
37
+ headers: dict[str, str],
38
+ rules: list[EndpointRule],
39
+ timeout: float = 20.0,
40
+ max_bytes: int = 8 * 1024 * 1024,
41
+ session: httpx.Client | None = None,
42
+ ):
43
+ u = urlparse(base_url)
44
+ if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
45
+ raise AccessError("Confluence base URL must be https")
46
+ self._base = base_url.rstrip("/") + "/"
47
+ self._base_origin = f"{u.scheme}://{u.netloc}"
48
+ self._base_path = u.path.rstrip("/")
49
+ self._rules = rules
50
+ self._http = session or httpx.Client(
51
+ headers={"Accept": "application/json", **headers},
52
+ timeout=timeout,
53
+ follow_redirects=False, # never carry credentials through redirects
54
+ )
55
+ self._max_bytes = max_bytes
56
+
57
+ # ---- the GET-only wrapper ----
58
+
59
+ def request(self, method: str, path: str, params: dict | None = None) -> httpx.Response:
60
+ if method.upper() != "GET":
61
+ raise SecurityException(
62
+ "Error: This agent is strictly read-only. "
63
+ "Write/Delete operations are forbidden."
64
+ )
65
+ api_path = self._validate_path(path)
66
+ resp = self._http.get(urljoin(self._base, api_path.lstrip("/")), params=params)
67
+ if resp.status_code >= 400:
68
+ raise AccessError(self._describe(resp))
69
+ if resp.status_code >= 300:
70
+ raise AccessError(
71
+ f"unexpected redirect ({resp.status_code}) — check the base URL/context path"
72
+ )
73
+ if len(resp.content) > self._max_bytes:
74
+ raise AccessError(f"response exceeds {self._max_bytes} bytes — refusing to buffer")
75
+ return resp
76
+
77
+ def get(self, path: str, params: dict | None = None) -> httpx.Response:
78
+ return self.request("GET", path, params)
79
+
80
+ # ---- validation ----
81
+
82
+ def _validate_path(self, path: str) -> str:
83
+ """Normalize to an API-relative path; enforce site + endpoint bounds."""
84
+ u = urlparse(path)
85
+ if u.scheme or u.netloc:
86
+ # absolute URL (e.g. pagination next-link) must stay inside
87
+ # the configured origin + API base path
88
+ full_base = self._base_origin + self._base_path
89
+ full = f"{u.scheme}://{u.netloc}{u.path}"
90
+ if not (full == full_base or full.startswith(full_base + "/")):
91
+ raise SecurityException(
92
+ "Error: request escapes the configured Confluence site — refused."
93
+ )
94
+ rel = u.path[len(self._base_path):].lstrip("/")
95
+ elif path.startswith("/"):
96
+ # site-relative path must live under the API base path
97
+ if self._base_path and not path.startswith(self._base_path + "/"):
98
+ raise SecurityException(
99
+ "Error: request escapes the configured Confluence site — refused."
100
+ )
101
+ rel = path[len(self._base_path):].lstrip("/")
102
+ else:
103
+ rel = path
104
+ if ".." in rel:
105
+ raise SecurityException("Error: path traversal rejected.")
106
+ if not any(r.pattern.match(rel) for r in self._rules):
107
+ raise SecurityException(
108
+ "Error: This agent is strictly read-only. "
109
+ f"Endpoint not on the allowed GET list: {rel}"
110
+ )
111
+ return rel
112
+
113
+ @staticmethod
114
+ def _describe(resp: httpx.Response) -> str:
115
+ s = resp.status_code
116
+ return {
117
+ 401: "authentication failed (401) — check the token and account",
118
+ 403: "permission denied (403) — your account can't view this content",
119
+ 404: "not found (404) — check the page ID/space and base URL",
120
+ 429: "rate limited (429) — retry after the server's Retry-After",
121
+ }.get(s, f"HTTP {s} from Confluence")
122
+
123
+
124
+ # ---- adapters ----
125
+
126
+ RULES_V2 = [
127
+ EndpointRule(re.compile(r"wiki/api/v2/pages/\d+/?$"), "page body"),
128
+ EndpointRule(re.compile(r"wiki/api/v2/pages/\d+/direct-children/?$"), "children"),
129
+ EndpointRule(re.compile(r"wiki/api/v2/folders/\d+/direct-children/?$"), "folder children"),
130
+ EndpointRule(re.compile(r"wiki/rest/api/user/current/?$"), "identity"),
131
+ ]
132
+
133
+ RULES_DC = [
134
+ EndpointRule(re.compile(r"(?:confluence/)?rest/api/content/\d+/?$"), "page body"),
135
+ EndpointRule(re.compile(r"(?:confluence/)?rest/api/content/\d+/child/page/?$"), "children"),
136
+ EndpointRule(re.compile(r"(?:confluence/)?rest/api/user/current/?$"), "identity"),
137
+ ]
138
+
139
+ def make_client(profile, token: str, cloud_id: str | None = None) -> ConfluenceClient:
140
+ """Build a client for the configured profile. Token only in headers."""
141
+ import base64
142
+
143
+ if profile.env == "cloud":
144
+ auth = base64.b64encode(f"{profile.email}:{token}".encode()).decode()
145
+ # scoped tokens go through the Atlassian gateway bound to one cloudId;
146
+ # the same v2 route shapes apply relative to that base path
147
+ base = (f"https://api.atlassian.com/ex/confluence/{cloud_id}"
148
+ if cloud_id else profile.base_url)
149
+ return ConfluenceClient(base, {"Authorization": f"Basic {auth}"}, RULES_V2)
150
+ return ConfluenceClient(
151
+ profile.base_url, {"Authorization": f"Bearer {token}"}, RULES_DC
152
+ )
@@ -0,0 +1,236 @@
1
+ """Durable, resumable breadth-first crawl of one page subtree.
2
+
3
+ Queue and progress live in the index DB — a crawl can be interrupted at any
4
+ point and resumed by job ID. Bodies are fetched only for pages admitted to the
5
+ root's descendant graph.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from urllib.parse import urlparse
16
+
17
+ from .client import AccessError, ConfluenceClient
18
+ from .extract import extract_chunks
19
+ from .index import Index
20
+
21
+
22
+ @dataclass
23
+ class CrawlJob:
24
+ job_id: str
25
+ root_page: str
26
+ queue: list[str] = field(default_factory=list)
27
+ visited: set[str] = field(default_factory=set)
28
+ errors: list[str] = field(default_factory=list)
29
+
30
+
31
+ def _fetch_children_v2(client: ConfluenceClient, page_id: str) -> list[tuple[str, str]]:
32
+ """(child_id, child_type) — pages and folders both; folders are traversed
33
+ but never indexed as content."""
34
+ out, cursor = [], None
35
+ while True:
36
+ params = {"limit": 250}
37
+ if cursor:
38
+ params["cursor"] = cursor
39
+ r = client.get(f"wiki/api/v2/pages/{page_id}/direct-children", params)
40
+ d = r.json()
41
+ for ch in d.get("results", []):
42
+ out.append((str(ch["id"]), ch.get("type", "page")))
43
+ nxt = (d.get("_links") or {}).get("next")
44
+ if not nxt:
45
+ break
46
+ cursor = nxt.split("cursor=")[-1].split("&")[0]
47
+ return out
48
+
49
+
50
+ def _fetch_folder_children_v2(client: ConfluenceClient, folder_id: str) -> list[tuple[str, str]]:
51
+ out, cursor = [], None
52
+ while True:
53
+ params = {"limit": 250}
54
+ if cursor:
55
+ params["cursor"] = cursor
56
+ r = client.get(f"wiki/api/v2/folders/{folder_id}/direct-children", params)
57
+ d = r.json()
58
+ for ch in d.get("results", []):
59
+ out.append((str(ch["id"]), ch.get("type", "page")))
60
+ nxt = (d.get("_links") or {}).get("next")
61
+ if not nxt:
62
+ break
63
+ cursor = nxt.split("cursor=")[-1].split("&")[0]
64
+ return out
65
+
66
+
67
+ def _fetch_children_dc(client: ConfluenceClient, page_id: str) -> list[tuple[str, str]]:
68
+ out, start = [], 0
69
+ while True:
70
+ r = client.get(f"rest/api/content/{page_id}/child/page",
71
+ params={"limit": 200, "start": start})
72
+ d = r.json()
73
+ for ch in d.get("results", []):
74
+ out.append((str(ch["id"]), "page"))
75
+ if d.get("size", 0) < d.get("limit", 200):
76
+ break
77
+ start += d["size"]
78
+ return out
79
+
80
+
81
+ def _abs_url(webui: str, base: str | None) -> str:
82
+ """Join Confluence's relative `_links.webui` onto a base so citations are
83
+ clickable; absolute links pass through unchanged."""
84
+ if not webui or urlparse(webui).scheme or not base:
85
+ return webui
86
+ return base.rstrip("/") + "/" + webui.lstrip("/")
87
+
88
+
89
+ def _fetch_page_v2(client: ConfluenceClient, page_id: str) -> dict:
90
+ d = client.get(f"wiki/api/v2/pages/{page_id}",
91
+ params={"body-format": "storage"}).json()
92
+ links = d.get("_links") or {}
93
+ return {
94
+ "page_id": str(d["id"]), "title": d.get("title", ""),
95
+ "storage": (d.get("body") or {}).get("storage", {}).get("value", ""),
96
+ "version": str((d.get("version") or {}).get("number", "")),
97
+ "url": _abs_url(links.get("webui", ""), links.get("base")),
98
+ }
99
+
100
+
101
+ def _fetch_page_dc(client: ConfluenceClient, page_id: str) -> dict:
102
+ d = client.get(f"rest/api/content/{page_id}",
103
+ params={"expand": "body.storage,version,ancestors"}).json()
104
+ links = d.get("_links") or {}
105
+ return {
106
+ "page_id": str(d["id"]), "title": d.get("title", ""),
107
+ "storage": (d.get("body") or {}).get("storage", {}).get("value", ""),
108
+ "version": str((d.get("version") or {}).get("number", "")),
109
+ "url": _abs_url(links.get("webui", ""), links.get("base")),
110
+ "ancestors": [str(a["id"]) for a in d.get("ancestors", [])],
111
+ }
112
+
113
+
114
+ class Crawler:
115
+ def __init__(self, client: ConfluenceClient, index: Index, env: str,
116
+ embedder=None, site_url: str | None = None):
117
+ self.client = client
118
+ self.index = index
119
+ self.env = env
120
+ self.embedder = embedder # retrieval.LocalEmbedder or None
121
+ # fallback base for page links when a response carries no _links.base
122
+ if site_url and env == "cloud" and not site_url.rstrip("/").endswith("/wiki"):
123
+ site_url = site_url.rstrip("/") + "/wiki"
124
+ self.site_url = site_url
125
+
126
+ def _children(self, pid: str, is_folder: bool = False):
127
+ if self.env == "cloud":
128
+ return (_fetch_folder_children_v2(self.client, pid) if is_folder
129
+ else _fetch_children_v2(self.client, pid))
130
+ return _fetch_children_dc(self.client, pid)
131
+
132
+ def _page(self, pid: str) -> dict:
133
+ page = (_fetch_page_v2(self.client, pid) if self.env == "cloud"
134
+ else _fetch_page_dc(self.client, pid))
135
+ page["url"] = _abs_url(page["url"], self.site_url)
136
+ return page
137
+
138
+ def start(self, root_page: str) -> str:
139
+ job_id = uuid.uuid4().hex[:12]
140
+ self.index.abandon_running_jobs()
141
+ self.index.create_job(job_id, root_page)
142
+ self.index.save_job(job_id, "running", [f"page:{root_page}"], [])
143
+ return job_id
144
+
145
+ def _checkpoint(self, job_id, queue, errors, seen, levels) -> None:
146
+ """Persist progress so an interrupted step loses no completed page."""
147
+ with self.index.lock, self.index.db:
148
+ self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
149
+ (f"seen:{job_id}", json.dumps(sorted(seen))))
150
+ self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
151
+ (f"levels:{job_id}", json.dumps(levels)))
152
+ self.index.db.execute(
153
+ "UPDATE jobs SET queue=?, errors=? WHERE job_id=?",
154
+ (json.dumps(queue), json.dumps(errors), job_id))
155
+
156
+ def step(self, job_id: str, batch: int | None = 10,
157
+ budget_s: float | None = None) -> dict:
158
+ """Process up to `batch` pages (None = whole queue), stopping early
159
+ once `budget_s` seconds have passed. The background worker calls this
160
+ unbounded; a bounded call stays under the IDE's request timeout."""
161
+ deadline = None if budget_s is None else time.monotonic() + budget_s
162
+ job = self.index.job(job_id)
163
+ if job is None:
164
+ raise AccessError(f"unknown job {job_id}")
165
+ if job["state"] != "running":
166
+ return {"job_id": job_id, "state": job["state"],
167
+ "remaining": len(job["queue"]), "errors": job["errors"]}
168
+
169
+ queue: list[str] = job["queue"]
170
+ errors: list[str] = job["errors"]
171
+ seen_raw = self.index.meta_get(f"seen:{job_id}")
172
+ seen: set[str] = set(json.loads(seen_raw)) if seen_raw else set()
173
+ levels_raw = self.index.meta_get(f"levels:{job_id}")
174
+ levels: dict[str, int] = (json.loads(levels_raw) if levels_raw
175
+ else {job["root_page"]: 0})
176
+ visited: set[str] = set()
177
+
178
+ processed = 0
179
+ while queue and (batch is None or processed < batch) and (
180
+ deadline is None or processed == 0 or time.monotonic() < deadline):
181
+ if processed:
182
+ self._checkpoint(job_id, queue, errors, seen, levels)
183
+ entry = queue.pop(0) # "page:123" or "folder:456"
184
+ is_folder = entry.startswith("folder:")
185
+ pid = entry.split(":", 1)[1] if ":" in entry else entry
186
+ if pid in visited or pid in seen:
187
+ continue
188
+ visited.add(pid)
189
+ processed += 1
190
+ if is_folder:
191
+ # traverse folders to reach descendant pages — never indexed
192
+ for cid, ctype in self._children(pid, is_folder=True):
193
+ if cid not in seen:
194
+ queue.append(f"{ctype}:{cid}")
195
+ continue
196
+ try:
197
+ page = self._page(pid)
198
+ except AccessError as e:
199
+ errors.append(f"page {pid}: {e}")
200
+ continue
201
+ if pid != job["root_page"]:
202
+ anc = page.get("ancestors") or []
203
+ if anc and job["root_page"] not in anc:
204
+ errors.append(f"page {pid}: outside root subtree — skipped")
205
+ continue
206
+ content_hash = hashlib.sha256(
207
+ page["storage"].encode()).hexdigest()
208
+ chunks, warns = extract_chunks(page["storage"])
209
+ for c in chunks:
210
+ c.chunk_id = f"{pid}:{content_hash[:8]}:{c.ordinal}"
211
+ vecs = None
212
+ if self.embedder is not None and chunks:
213
+ vecs = self.embedder.embed(
214
+ [f"{c.heading_path}\n{c.text}" for c in chunks])
215
+ self.index.upsert_page({
216
+ "page_id": pid, "parent_id": None, "title": page["title"],
217
+ "url": page["url"], "level": levels.get(pid, 0),
218
+ "version": page["version"], "content_hash": content_hash,
219
+ "warnings": warns}, chunks, vecs)
220
+ seen.add(pid)
221
+ for cid, ctype in self._children(pid, is_folder=False):
222
+ if cid not in seen:
223
+ levels.setdefault(cid, levels.get(pid, 0) + 1)
224
+ queue.append(f"{ctype}:{cid}")
225
+
226
+ self._checkpoint(job_id, queue, errors, seen, levels)
227
+
228
+ if queue:
229
+ self.index.save_job(job_id, "running", queue, errors)
230
+ state = "running"
231
+ else:
232
+ self.index.publish(seen)
233
+ self.index.save_job(job_id, "done", [], errors)
234
+ state = "done"
235
+ return {"job_id": job_id, "state": state, "remaining": len(queue),
236
+ "pages_seen": len(seen), "errors": errors}