lanternkb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. lanternkb/__init__.py +3 -0
  2. lanternkb/chunking.py +166 -0
  3. lanternkb/cli.py +889 -0
  4. lanternkb/config.py +257 -0
  5. lanternkb/control.py +586 -0
  6. lanternkb/doctor.py +314 -0
  7. lanternkb/embed/__init__.py +474 -0
  8. lanternkb/extract/__init__.py +81 -0
  9. lanternkb/extract/archive.py +122 -0
  10. lanternkb/extract/audio.py +220 -0
  11. lanternkb/extract/base.py +95 -0
  12. lanternkb/extract/linux_sandbox.py +224 -0
  13. lanternkb/extract/markup.py +224 -0
  14. lanternkb/extract/ocr.py +141 -0
  15. lanternkb/extract/odf.py +333 -0
  16. lanternkb/extract/office.py +367 -0
  17. lanternkb/extract/pdf.py +135 -0
  18. lanternkb/extract/pool.py +660 -0
  19. lanternkb/extract/rtf.py +194 -0
  20. lanternkb/extract/testing.py +311 -0
  21. lanternkb/extract/text.py +52 -0
  22. lanternkb/extract/tools.py +44 -0
  23. lanternkb/extract/worker.py +386 -0
  24. lanternkb/features/__init__.py +214 -0
  25. lanternkb/features/builtin/embedder.bge-small.toml +14 -0
  26. lanternkb/features/builtin/embedder.embeddinggemma-300m.toml +14 -0
  27. lanternkb/features/builtin/embedder.qwen3-0.6b.toml +14 -0
  28. lanternkb/features/builtin/extractor.audio.toml +22 -0
  29. lanternkb/features/builtin/extractor.code.toml +32 -0
  30. lanternkb/features/builtin/extractor.images.toml +22 -0
  31. lanternkb/features/builtin/extractor.office.toml +22 -0
  32. lanternkb/features/builtin/extractor.opendocument.toml +23 -0
  33. lanternkb/features/builtin/extractor.pdf.toml +22 -0
  34. lanternkb/features/builtin/extractor.text.toml +26 -0
  35. lanternkb/features/builtin/reranker.gte-modernbert.toml +14 -0
  36. lanternkb/features/builtin/scanner.secrets.toml +14 -0
  37. lanternkb/fsutil.py +55 -0
  38. lanternkb/globs.py +158 -0
  39. lanternkb/hints.py +53 -0
  40. lanternkb/ingest.py +703 -0
  41. lanternkb/models.py +171 -0
  42. lanternkb/plugins.py +250 -0
  43. lanternkb/policy.py +275 -0
  44. lanternkb/power.py +70 -0
  45. lanternkb/redaction.py +151 -0
  46. lanternkb/rerank.py +273 -0
  47. lanternkb/scan/__init__.py +1 -0
  48. lanternkb/scan/engine.py +1434 -0
  49. lanternkb/scan/rules/gitleaks.LICENSE +21 -0
  50. lanternkb/scan/rules/gitleaks.toml +3209 -0
  51. lanternkb/scan/rules/lanternkb.toml +852 -0
  52. lanternkb/scan/rules.py +409 -0
  53. lanternkb/scan/views.py +605 -0
  54. lanternkb/schedule.py +154 -0
  55. lanternkb/search.py +679 -0
  56. lanternkb/server.py +818 -0
  57. lanternkb/store.py +1123 -0
  58. lanternkb/testing.py +161 -0
  59. lanternkb/text.py +33 -0
  60. lanternkb/tiers.py +107 -0
  61. lanternkb-0.1.0.dist-info/METADATA +117 -0
  62. lanternkb-0.1.0.dist-info/RECORD +66 -0
  63. lanternkb-0.1.0.dist-info/WHEEL +4 -0
  64. lanternkb-0.1.0.dist-info/entry_points.txt +2 -0
  65. lanternkb-0.1.0.dist-info/licenses/LICENSE +202 -0
  66. lanternkb-0.1.0.dist-info/licenses/NOTICE +5 -0
lanternkb/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """lanternkb: local-only search over your own files."""
2
+
3
+ __version__ = "0.1.0"
lanternkb/chunking.py ADDED
@@ -0,0 +1,166 @@
1
+ """Structure-aware chunking (moved from the phase 1 baselines unchanged; phase 5 tunes it).
2
+
3
+ Markdown: split at headings, then pack paragraphs up to MAX_CHARS. Python: one chunk per
4
+ top-level function or class (plus the module preamble), with long ones split by lines.
5
+ Plain text: pack paragraphs. Every chunk records its file and line range.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import re
12
+ from dataclasses import dataclass, replace
13
+ from pathlib import Path
14
+
15
+ from lanternkb.text import split_identifier
16
+
17
+ # Chosen on dev in phase 5 (bench/phase5-dev-log.md); phase 1 used 1,500 and 60.
18
+ MAX_CHARS = 800 # about 150-250 tokens
19
+ CODE_MAX_LINES = 32
20
+ # A line longer than MAX_CHARS (a paragraph exported as one line, minified code) is split
21
+ # at spaces, so no chunk outgrows what the embedder reads. Off only to reproduce phase 1.
22
+ SPLIT_LONG_LINES = True
23
+
24
+
25
+ def _split_line(line: str) -> list[str]:
26
+ """Pieces of at most MAX_CHARS, broken at the last space before the limit if any."""
27
+ if not SPLIT_LONG_LINES or len(line) <= MAX_CHARS:
28
+ return [line]
29
+ out = []
30
+ while len(line) > MAX_CHARS:
31
+ cut = line.rfind(" ", MAX_CHARS // 2, MAX_CHARS + 1)
32
+ cut = cut if cut > 0 else MAX_CHARS
33
+ out.append(line[:cut])
34
+ line = line[cut:].lstrip(" ")
35
+ return [*out, line] if line else out
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Chunk:
40
+ path: str
41
+ start: int # 1-based line numbers, inclusive
42
+ end: int
43
+ text: str
44
+ location: str | None = None # "lines 3-40", "page 3, lines 1-20", "slide 2", ...
45
+ # Holds a piece of a line cut by `_split_line`: its text has a line start or end the
46
+ # scanned text did not, so it is scanned again before it is stored (review 12).
47
+ split: bool = False
48
+
49
+
50
+ def _pack(path: str, lines: list[str], first_line: int) -> list[Chunk]:
51
+ """Pack blank-line-separated paragraphs into chunks of at most MAX_CHARS."""
52
+ chunks: list[Chunk] = []
53
+ buf: list[str] = []
54
+ start = first_line
55
+ size = 0
56
+ cut = False # buf holds a piece of a cut line
57
+ for i, whole in enumerate(lines):
58
+ lineno = first_line + i
59
+ pieces = _split_line(whole)
60
+ for j, line in enumerate(pieces):
61
+ cut_here = j > 0 or not line.strip() or size > MAX_CHARS
62
+ if size + len(line) > MAX_CHARS and buf and cut_here:
63
+ end = lineno if j > 0 else lineno - 1
64
+ chunks.append(Chunk(path, start, end, "\n".join(buf).strip(), split=cut))
65
+ buf, size, start, cut = [], 0, lineno, False
66
+ buf.append(line)
67
+ size += len(line) + 1
68
+ cut = cut or len(pieces) > 1
69
+ if "".join(buf).strip():
70
+ chunks.append(Chunk(path, start, first_line + len(lines) - 1, "\n".join(buf).strip(),
71
+ split=cut)) # fmt: skip
72
+ return chunks
73
+
74
+
75
+ def chunk_markdown(path: str, text: str) -> list[Chunk]:
76
+ lines = text.splitlines()
77
+ sections: list[tuple[int, int]] = []
78
+ start = 0
79
+ for i, line in enumerate(lines):
80
+ if re.match(r"#{1,6}\s", line) and i > start:
81
+ sections.append((start, i))
82
+ start = i
83
+ sections.append((start, len(lines)))
84
+ out: list[Chunk] = []
85
+ for a, b in sections:
86
+ out += _pack(path, lines[a:b], a + 1)
87
+ return out
88
+
89
+
90
+ def chunk_python(path: str, text: str) -> list[Chunk]:
91
+ lines = text.splitlines()
92
+ try:
93
+ tree = ast.parse(text)
94
+ except Exception: # SyntaxError, but also MemoryError/RecursionError on hostile input
95
+ return _pack(path, lines, 1)
96
+ spans: list[tuple[int, int]] = []
97
+ prev_end = 0
98
+ for node in tree.body:
99
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
100
+ first = min([node.lineno, *(d.lineno for d in node.decorator_list)])
101
+ if first - 1 > prev_end:
102
+ spans.append((prev_end + 1, first - 1))
103
+ spans.append((first, node.end_lineno or first))
104
+ prev_end = node.end_lineno or first
105
+ if prev_end < len(lines):
106
+ spans.append((prev_end + 1, len(lines)))
107
+ out: list[Chunk] = []
108
+ for a, b in spans:
109
+ for s in range(a, b + 1, CODE_MAX_LINES):
110
+ e = min(b, s + CODE_MAX_LINES - 1)
111
+ body = "\n".join(lines[s - 1 : e]).strip()
112
+ if body and SPLIT_LONG_LINES and len(body) > 2 * MAX_CHARS:
113
+ out += _pack(path, lines[s - 1 : e], s) # very long lines: pack by size
114
+ elif body:
115
+ out.append(Chunk(path, s, e, body))
116
+ return out
117
+
118
+
119
+ def chunk_text(rel: str, text: str) -> list[Chunk]:
120
+ """Chunk already-extracted (and redacted) text, choosing the chunker by file type."""
121
+ if rel.endswith(".py"):
122
+ return chunk_python(rel, text)
123
+ if rel.endswith(".md"):
124
+ return chunk_markdown(rel, text)
125
+ return _pack(rel, text.splitlines(), 1)
126
+
127
+
128
+ def chunk_file(corpus_dir: Path, rel: str) -> list[Chunk]:
129
+ return chunk_text(rel, (corpus_dir / rel).read_text(encoding="utf-8", errors="replace"))
130
+
131
+
132
+ def corpus_files(corpus_dir: Path) -> list[str]:
133
+ return sorted(
134
+ p.relative_to(corpus_dir).as_posix()
135
+ for p in corpus_dir.rglob("*")
136
+ if p.is_file() and p.suffix in {".md", ".py", ".txt"}
137
+ )
138
+
139
+
140
+ def split_identifiers(text: str) -> str:
141
+ """Words from code identifiers: `parseConfigV2 _merge_url` -> `parse config v2 merge url`."""
142
+ words: list[str] = []
143
+ for tok in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", text):
144
+ parts = split_identifier(tok)
145
+ if len(parts) > 1:
146
+ words += parts
147
+ return " ".join(words)
148
+
149
+
150
+ def chunk_segments(rel: str, segments: list[dict[str, object]]) -> list[Chunk]:
151
+ """Chunks from extracted segments, each tagged with where it came from.
152
+
153
+ A single whole-file segment (text and code) is chunked by structure, as before. Paged or
154
+ sheeted content (PDF pages, slides, sheet ranges, audio spans) is packed per segment.
155
+ """
156
+ if len(segments) == 1 and str(segments[0]["location"]).startswith("lines "):
157
+ return [replace(c, location=f"lines {c.start}-{c.end}")
158
+ for c in chunk_text(rel, str(segments[0]["text"]))] # fmt: skip
159
+ out: list[Chunk] = []
160
+ for seg in segments:
161
+ loc = str(seg["location"])
162
+ packed = _pack(rel, str(seg["text"]).splitlines(), 1)
163
+ for c in packed:
164
+ where = loc if len(packed) == 1 else f"{loc}, lines {c.start}-{c.end}"
165
+ out.append(replace(c, location=where))
166
+ return out