blun-king-cli 9.1.623 → 9.1.625

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 (142) hide show
  1. package/CHANGELOG.md +62 -209
  2. package/LIESMICH.txt +57 -74
  3. package/README.md +76 -119
  4. package/agent-spine-plugin/scripts/check-install.js +4 -4
  5. package/agent-spine-plugin/src/hook.js +0 -4
  6. package/agent-spine-plugin/src/lib/documents.js +1 -1
  7. package/agent-spine-plugin/src/lib/hook-context.js +0 -1
  8. package/agent-spine-plugin/src/lib/host-instruction-budget.js +2 -1
  9. package/agent-spine-plugin/src/lib/preflight.js +9 -38
  10. package/agent-spine-plugin/src/lib/source-roots.js +1 -1
  11. package/bin/agentspine-king-goal-inbox.mjs +127 -127
  12. package/bin/agentspine-king-goal-intake.mjs +106 -106
  13. package/bin/agentspine-king-host-runner.mjs +109 -109
  14. package/bin/agentspine-king-snapshot-policy.cjs +80 -80
  15. package/bin/agentspine-king-status-policy.mjs +226 -226
  16. package/bin/agentspine-king-worker-host.mjs +160 -160
  17. package/bin/core-bootstrap.js +71 -100
  18. package/bin/launcher-mode.js +93 -93
  19. package/bin/launcher-restart-policy.cjs +131 -131
  20. package/bin/launcher-runtime.js +89 -254
  21. package/bin/managed-node.js +186 -186
  22. package/bin/managed-plugin-selection.cjs +109 -116
  23. package/bin/native-module-repair.js +201 -201
  24. package/bin/node-runtime.js +34 -34
  25. package/bin/node-version.js +49 -49
  26. package/bin/plugin-bootstrap.js +149 -172
  27. package/bin/private-paths.js +1578 -1624
  28. package/bin/runtime-exit-ledger.cjs +144 -144
  29. package/bin/runtime-exit-ledger.d.cts +23 -23
  30. package/bin/standard-tools-bootstrap.js +277 -272
  31. package/bin/turn-thinking-policy.cjs +82 -82
  32. package/bin/update-copy.js +200 -200
  33. package/bin/update-lease.js +597 -599
  34. package/bin/update-notice.js +1568 -1568
  35. package/bin/verify-agent-behavior.cjs +122 -122
  36. package/bin/verify-agent-components.cjs +104 -104
  37. package/bin/verify-bundled-agent-sources.cjs +91 -96
  38. package/bin/windows-node-crash-dump.cjs +289 -289
  39. package/blun.mjs +194198 -189561
  40. package/bundled-agent-sources.json +26 -30
  41. package/codebase-index/codebase_index.py +462 -470
  42. package/dist-web/assets/{CodeBlockNode-TKKkJX8L.js → CodeBlockNode-BMbuG85x.js} +1 -1
  43. package/dist-web/assets/{CodeBlockShell.vue_vue_type_style_index_0_lang-CUj2Ds5x.js → CodeBlockShell.vue_vue_type_style_index_0_lang-C1fv7l_Z.js} +1 -1
  44. package/dist-web/assets/DesignSystemView-6HgAUEmC.css +1 -0
  45. package/dist-web/assets/DesignSystemView-CEEBqXSX.js +12 -0
  46. package/dist-web/assets/{Tooltip-CTWv7WCU.js → Tooltip--YoauJ24.js} +1 -1
  47. package/dist-web/assets/{abnfDiagram-N423BO3Z-DA8V8Zjh.js → abnfDiagram-N423BO3Z-DZvBWkRg.js} +1 -1
  48. package/dist-web/assets/{arc-YtddKbdY.js → arc-B04JATeX.js} +1 -1
  49. package/dist-web/assets/{architectureDiagram-T3A2C74G-CGEiObmo.js → architectureDiagram-T3A2C74G-DmYnMep2.js} +1 -1
  50. package/dist-web/assets/{blockDiagram-VBNYF7ZC-Bd5Vt50e.js → blockDiagram-VBNYF7ZC-DJ1gPS1C.js} +1 -1
  51. package/dist-web/assets/{c4Diagram-5PPSVZJV-D2ApPM54.js → c4Diagram-5PPSVZJV-DmjxvUbd.js} +1 -1
  52. package/dist-web/assets/channel-472fcEvw.js +1 -0
  53. package/dist-web/assets/{chunk-2GRJ4B5K-BDcIEZSl.js → chunk-2GRJ4B5K-AB0raj3I.js} +1 -1
  54. package/dist-web/assets/{chunk-2Q5K7J3B-6aSabCX7.js → chunk-2Q5K7J3B-Drf039vM.js} +1 -1
  55. package/dist-web/assets/{chunk-5RXB4S5H-BFRQvIJS.js → chunk-5RXB4S5H-D5ILttmz.js} +1 -1
  56. package/dist-web/assets/{chunk-5VM5RSS4-ChAa-DNY.js → chunk-5VM5RSS4-DrLSdi8y.js} +1 -1
  57. package/dist-web/assets/{chunk-6Q2QTUOP-BIfrb2Jc.js → chunk-6Q2QTUOP-LiaqdEOQ.js} +1 -1
  58. package/dist-web/assets/{chunk-GF5L2VYU-BR0gvtWI.js → chunk-GF5L2VYU-AIYkb--o.js} +1 -1
  59. package/dist-web/assets/{chunk-JWPE2WC7-BgXKsx5E.js → chunk-JWPE2WC7-CogdNl0i.js} +1 -1
  60. package/dist-web/assets/{chunk-KBJHAD2P-I8mrZhpz.js → chunk-KBJHAD2P-DHjpNtZv.js} +1 -1
  61. package/dist-web/assets/{chunk-RYQCIY6F-BMPaem92.js → chunk-RYQCIY6F-BUSOqH2X.js} +1 -1
  62. package/dist-web/assets/{chunk-XXDRQBXY-COy0J-0j.js → chunk-XXDRQBXY-BpoMV0bh.js} +1 -1
  63. package/dist-web/assets/classDiagram-JCYQIIEL-BTZeLngj.js +1 -0
  64. package/dist-web/assets/classDiagram-v2-OCEON4UE-BTZeLngj.js +1 -0
  65. package/dist-web/assets/{cose-bilkent-JH36ORCC-MosTTRdo.js → cose-bilkent-JH36ORCC-BJr2I61o.js} +1 -1
  66. package/dist-web/assets/{cynefin-VYW2F7L2-5tjPgrkP.js → cynefin-VYW2F7L2-HtysQZ4A.js} +1 -1
  67. package/dist-web/assets/{cynefinDiagram-MW4NZA55-CbDcdInR.js → cynefinDiagram-MW4NZA55-CcpC_2uv.js} +1 -1
  68. package/dist-web/assets/{dagre-VZM6K2ZE-YaBom1ZH.js → dagre-VZM6K2ZE-D8Ao0LUg.js} +1 -1
  69. package/dist-web/assets/{diagram-7IWD3JNH-Da4m-Hgz.js → diagram-7IWD3JNH-BITiLhjX.js} +1 -1
  70. package/dist-web/assets/{diagram-B4RE2ZJO-CAUUegxg.js → diagram-B4RE2ZJO-CJtwfy9f.js} +1 -1
  71. package/dist-web/assets/{diagram-LBJQPF4R-o21mWKG4.js → diagram-LBJQPF4R-BsVTh6w8.js} +1 -1
  72. package/dist-web/assets/{diagram-Q27KOJAE-BA3z3sXf.js → diagram-Q27KOJAE-pA_MUCUf.js} +1 -1
  73. package/dist-web/assets/{diagram-UB23O5K3-CGRdSSDK.js → diagram-UB23O5K3-CIY8z7vu.js} +1 -1
  74. package/dist-web/assets/{ebnfDiagram-BXEA7PRR-Bw-iU9FP.js → ebnfDiagram-BXEA7PRR-C3FMioqp.js} +1 -1
  75. package/dist-web/assets/{erDiagram-JOGREHBK-BwM1yiR0.js → erDiagram-JOGREHBK-CzoyKmKc.js} +1 -1
  76. package/dist-web/assets/{flowDiagram-UKHOOZJN-z05eOR1Q.js → flowDiagram-UKHOOZJN-DOqfushy.js} +1 -1
  77. package/dist-web/assets/{ganttDiagram-PKOTCBZU-BMUubNTl.js → ganttDiagram-PKOTCBZU-Cf9XQSWl.js} +1 -1
  78. package/dist-web/assets/{gitGraphDiagram-DS77QQ5N-Dccbi1wU.js → gitGraphDiagram-DS77QQ5N-DHUDofE5.js} +1 -1
  79. package/dist-web/assets/{index-h0KEOmkU.js → index-B6qEMQMx.js} +14 -14
  80. package/dist-web/assets/{index-C9eDH3u-.css → index-BmKj7CB1.css} +1 -1
  81. package/dist-web/assets/{index-BBbC5LRF.js → index-DokxDX1w.js} +1 -1
  82. package/dist-web/assets/{index-D48p0M2S.js → index-auojtiAA.js} +2 -2
  83. package/dist-web/assets/{index10-Qbuw1N6u.js → index10-Drb2n5e5.js} +1 -1
  84. package/dist-web/assets/{index11-g8v025wN.js → index11-Cx-aV4G2.js} +1 -1
  85. package/dist-web/assets/{index5-BYNcV8BY.js → index5-Dg3qGTyH.js} +1 -1
  86. package/dist-web/assets/{index6-BetANtnV.js → index6-BgvtIICP.js} +1 -1
  87. package/dist-web/assets/{index7-BcPbgCKN.js → index7-uEnniPQG.js} +1 -1
  88. package/dist-web/assets/{index8-B1lp-RvF.js → index8-h4IKc0ek.js} +2 -2
  89. package/dist-web/assets/{infoDiagram-6WML65LV-BF_pbUnN.js → infoDiagram-6WML65LV-D-3tvEiJ.js} +1 -1
  90. package/dist-web/assets/{ishikawaDiagram-WSZJBQD7-Dedv4urS.js → ishikawaDiagram-WSZJBQD7-DIIBNv7a.js} +1 -1
  91. package/dist-web/assets/{journeyDiagram-NVQOT4AX-B1Ex3b6o.js → journeyDiagram-NVQOT4AX-DP4r1Vlx.js} +1 -1
  92. package/dist-web/assets/{kanban-definition-27J2QSJJ-BtCGliBH.js → kanban-definition-27J2QSJJ-C3_Ztgxv.js} +1 -1
  93. package/dist-web/assets/{linear-ByItc8Qr.js → linear-DnqhIw-y.js} +1 -1
  94. package/dist-web/assets/{mermaid.core-B1M2O7nN.js → mermaid.core-xYUgBa3T.js} +4 -4
  95. package/dist-web/assets/{mindmap-definition-FAOFIHXS-D0UHKUyj.js → mindmap-definition-FAOFIHXS-l-dq-zfr.js} +1 -1
  96. package/dist-web/assets/{pegDiagram-VL7TDLO6-D7zYCVmG.js → pegDiagram-VL7TDLO6-C8vXQiaJ.js} +1 -1
  97. package/dist-web/assets/{pieDiagram-7S7Q4E2Y-Bd0i9CHa.js → pieDiagram-7S7Q4E2Y-CyNRSsI6.js} +1 -1
  98. package/dist-web/assets/{quadrantDiagram-CIZ2JOQS-MG-Ug47d.js → quadrantDiagram-CIZ2JOQS-D-w8AD_g.js} +1 -1
  99. package/dist-web/assets/{railroadDiagram-AXF67PYL-AZdl90Gj.js → railroadDiagram-AXF67PYL-BLkyQCWy.js} +1 -1
  100. package/dist-web/assets/{requirementDiagram-LRYGKXZP-DjplF9kt.js → requirementDiagram-LRYGKXZP-CEWO7XfT.js} +1 -1
  101. package/dist-web/assets/{sankeyDiagram-W5VNT64P-Bi9Zgfjs.js → sankeyDiagram-W5VNT64P-ByGoAaGj.js} +1 -1
  102. package/dist-web/assets/{sequenceDiagram-SI44F4Z6-ChEpdCvb.js → sequenceDiagram-SI44F4Z6-Bhn9GQtd.js} +1 -1
  103. package/dist-web/assets/{sizeCapture-X5ZJPWSS-B5RFUdaa.js → sizeCapture-X5ZJPWSS-jVKR9CTu.js} +1 -1
  104. package/dist-web/assets/{stateDiagram-OKZ733FA-BkRblSKp.js → stateDiagram-OKZ733FA-wW3XbegA.js} +1 -1
  105. package/dist-web/assets/stateDiagram-v2-UEYNNEHI-B8aids-Z.js +1 -0
  106. package/dist-web/assets/{swimlanes-SLNWSIFB-DLXH6Zhc.js → swimlanes-SLNWSIFB-B8QyFS7G.js} +2 -2
  107. package/dist-web/assets/swimlanesDiagram-ULZ7WXOC-B8XAkdSm.js +8 -0
  108. package/dist-web/assets/{timeline-definition-Z64GVDOM-C_N88siO.js → timeline-definition-Z64GVDOM-DpvxS-1A.js} +1 -1
  109. package/dist-web/assets/{vennDiagram-T6HMQDX7-DMsnvDt0.js → vennDiagram-T6HMQDX7-BzGM0ihY.js} +1 -1
  110. package/dist-web/assets/{vue.runtime.esm-bundler-B-3uIy85.js → vue.runtime.esm-bundler-BOcpwIX1.js} +1 -1
  111. package/dist-web/assets/{wardleyDiagram-T6FBY63Y-C9SJIPzq.js → wardleyDiagram-T6FBY63Y-2DKAOs5e.js} +1 -1
  112. package/dist-web/assets/{xychartDiagram-ELKLHX3M-DZLWA6Go.js → xychartDiagram-ELKLHX3M-B0SP0TXd.js} +1 -1
  113. package/dist-web/index.html +32 -32
  114. package/dist-web/vis/index.html.gz +0 -0
  115. package/package.json +1 -1
  116. package/scripts/fix-node-pty-perms.js +53 -53
  117. package/standard-skills/blun-app-design-system/SKILL.md +30 -30
  118. package/standard-skills/blun-app-design-system/provenance.json +17 -17
  119. package/standard-skills/blun-app-design-system/references/tokens.md +48 -48
  120. package/standard-skills/blun-app-design-system/scripts/check-blun-design.mjs +49 -49
  121. package/standard-skills/translate-native/README.md +1 -1
  122. package/standard-skills/translate-native/scripts/blun_language_guard.py +2 -0
  123. package/standard-skills/translate-native/scripts/language_gateway.py +2 -0
  124. package/standard-skills/translate-native/scripts/pre_output_guard.py +2 -0
  125. package/standard-tools/language-guard/blun_language_guard.py +2 -0
  126. package/standard-tools/language-guard/language_gateway.py +2 -0
  127. package/standard-tools/language-guard/pre_output_guard.py +2 -0
  128. package/telegram-plugin/blun.plugin.json +20 -20
  129. package/telegram-plugin/commands/access.md +37 -37
  130. package/telegram-plugin/dist/bridge.mjs +310 -187
  131. package/telegram-plugin/dist/mcp-server.mjs +174 -261
  132. package/telegram-plugin/dist/noise.mjs +1 -19
  133. package/worker-host.mjs +13879 -17458
  134. package/bin/profile-handoff.cjs +0 -152
  135. package/bin/profile-launch.cjs +0 -120
  136. package/dist-web/assets/DesignSystemView-DwK1aQMo.js +0 -12
  137. package/dist-web/assets/DesignSystemView-szjTj7ns.css +0 -1
  138. package/dist-web/assets/channel-BnluexuN.js +0 -1
  139. package/dist-web/assets/classDiagram-JCYQIIEL-BliFdSD9.js +0 -1
  140. package/dist-web/assets/classDiagram-v2-OCEON4UE-BliFdSD9.js +0 -1
  141. package/dist-web/assets/stateDiagram-v2-UEYNNEHI-fVkQcACW.js +0 -1
  142. package/dist-web/assets/swimlanesDiagram-ULZ7WXOC-Bg-BiZJQ.js +0 -8
@@ -1,478 +1,470 @@
1
- #!/usr/bin/env python3
2
- """codebase-index — lokaler semantischer Index ueber einen Git-Code-Baum.
3
-
4
- F1-Bau nach Konzept (handoffs/codebase-verstaendnis-lokal-konzept.md),
5
- Anforderungen:
6
- - Streaming in vorallokierte float32-Matrix (memmap), KEINE Vektoren-Liste
7
- (Run-3-Befund: Liste trieb RAM-Spitze auf 10,5 GB).
8
- - Inkrementell: Content-Hash je Datei im Manifest, Delta statt Vollindex.
9
- - Query mit Frische-Auskunft + Selbstauskunft in JEDER Antwort.
10
- - Lokales Query-Metadatenlog (query-log.jsonl), ohne Fragetext.
11
-
12
- Aufruf:
13
- python codebase_index.py build <repo>
14
- python codebase_index.py update <repo>
15
- python codebase_index.py query <repo> "<frage>" [--top 5]
16
- python codebase_index.py sample <repo>
17
-
18
- Index-Ort: ~/.blun/codebase-index/<workspace-hash>/ (sichtbar, mit Rueckweg).
19
- """
20
- import hashlib
21
- import json
22
- import os
23
- import subprocess
24
- import sys
25
- import time
26
- from datetime import datetime, timezone
27
-
28
- import numpy as np
29
-
30
- CODE_EXT = (
31
- ".ts", ".tsx", ".js", ".mjs", ".cjs", ".py", ".html", ".css",
32
- ".json", ".md", ".toml", ".yml", ".yaml", ".sh", ".ps1", ".sql",
33
- )
34
- CHUNK_SIZE = 1000
35
- CHUNK_OVERLAP = 100
36
- EMBED_DIM = 384
37
- BATCH = 256
38
- MODEL_NAME = "BAAI/bge-small-en-v1.5"
39
- JINA_CODE_MODEL_NAME = "jinaai/jina-embeddings-v2-base-code"
40
- KNOWN_MODELS = {
41
- MODEL_NAME: 384,
42
- JINA_CODE_MODEL_NAME: 768,
43
- }
44
- MODEL_ALIASES = {
45
- "bge": MODEL_NAME,
46
- "jina-code": JINA_CODE_MODEL_NAME,
47
- }
48
- AUTO_MODEL_ORDER = (JINA_CODE_MODEL_NAME, MODEL_NAME)
49
-
50
- # Iteration 2: Rausch-Filter — Backup-Kopien und Vendor-
51
- # Buelle machten 66% aller Index-Rows aus und dominieren Top-5.
52
- EXCLUDE_PREFIXES = ("backup-", "vendor/")
53
-
54
- # Aktives Modell + Dim zur Laufzeit (wird per --model ueberschrieben).
55
- _active_model_name = MODEL_NAME
56
- _active_dim = EMBED_DIM
57
-
58
- # Stichprobe V3: Fragen PARAPHRASIERT aus gelesenem
59
- # Dateiinhalt — keine wörtlichen Bezeichner/Funktionsnamen/Kommentare aus
60
- # den Dateien (misst semantische Suche, nicht Wortgleichheit).
61
- # api-chat.js: routet Chat-Nachrichten ans Modell, Fallback-Kette, Stream.
62
- # terminal-manager.js: startet Shell-Prozesse mit Sandbox-/Approval-Modi.
63
- # renderer-view-shell.js: wechselt Login-/Willkommens-/Chat-Ansicht.
64
- SAMPLE_QUERIES = [
65
- ("how are conversation messages routed to the right model with a fallback chain", "api-chat"),
66
- ("starting sandboxed shell processes with approval modes and output history", "terminal-manager"),
67
- ("switching between sign-in screen, welcome page and main conversation view", "renderer-view-shell"),
68
- ]
69
-
70
- SELSTAUSKUNFT = (
71
- "HINWEIS: semantischer Index, kann falsch liegen — Fundstelle vor "
72
- "Verwendung in der Datei verifizieren."
73
- )
74
-
75
-
76
- def canonical_repo_path(repo: str) -> str:
77
- return os.path.normcase(os.path.realpath(os.path.abspath(repo)))
78
-
79
-
80
- def index_root() -> str:
81
- return os.path.join(os.path.expanduser("~"), ".blun", "codebase-index")
82
-
83
-
84
- def index_dir(repo: str) -> str:
85
- raw = canonical_repo_path(repo) + "|" + _active_model_name
86
- key = hashlib.sha256(raw.encode()).hexdigest()[:12]
87
- return os.path.join(index_root(), key)
88
-
89
-
90
- def normalize_model_selector(selector: str) -> str:
91
- if selector == "auto":
92
- return selector
93
- model = MODEL_ALIASES.get(selector, selector)
94
- if model not in KNOWN_MODELS:
95
- allowed = ", ".join(("auto", *MODEL_ALIASES, *KNOWN_MODELS))
96
- raise ValueError(f"Unsupported index model {selector!r}; choose one of: {allowed}")
97
- return model
98
-
99
-
100
- def find_existing_indexes(repo: str) -> list[tuple[str, dict]]:
101
- root = index_root()
102
- if not os.path.isdir(root):
103
- return []
104
- canonical_repo = canonical_repo_path(repo)
105
- canonical_root = os.path.realpath(root)
106
- candidates = []
107
- for entry in os.scandir(root):
108
- if not entry.is_dir(follow_symlinks=False):
109
- continue
110
- directory = os.path.realpath(entry.path)
111
- try:
112
- if os.path.commonpath((canonical_root, directory)) != canonical_root:
113
- continue
114
- except ValueError:
115
- continue
116
- manifest_path = os.path.join(directory, "manifest.json")
117
- vectors_path = os.path.join(directory, "vectors.npy")
118
- if not os.path.isfile(manifest_path) or not os.path.isfile(vectors_path):
119
- continue
120
- try:
121
- manifest = load_manifest(directory)
122
- except (OSError, ValueError, TypeError):
123
- continue
124
- model = manifest.get("model")
125
- if model not in KNOWN_MODELS or manifest.get("dim") != KNOWN_MODELS[model]:
126
- continue
127
- manifest_repo = manifest.get("repo")
128
- if not isinstance(manifest_repo, str):
129
- continue
130
- if canonical_repo_path(manifest_repo) != canonical_repo:
131
- continue
132
- candidates.append((directory, manifest))
133
- return sorted(candidates, key=lambda item: item[0])
134
-
135
-
136
- def resolve_existing_index(repo: str, selector: str = "auto") -> tuple[str, dict]:
137
- requested = normalize_model_selector(selector)
138
- candidates = find_existing_indexes(repo)
139
- model_order = AUTO_MODEL_ORDER if requested == "auto" else (requested,)
140
- for model in model_order:
141
- matches = [item for item in candidates if item[1]["model"] == model]
142
- if matches:
143
- return max(
144
- matches,
145
- key=lambda item: (str(item[1].get("built_at", "")), item[0]),
146
- )
147
- available = sorted({item[1]["model"] for item in candidates})
148
- suffix = f"; available for this repository: {', '.join(available)}" if available else ""
149
- raise FileNotFoundError(
150
- f"No compatible codebase index for {os.path.abspath(repo)!r} and selector {selector!r}{suffix}"
151
- )
152
-
153
-
154
- def activate_model(model: str, dim: int | None = None) -> None:
155
- global _active_model_name, _active_dim
156
- normalized = normalize_model_selector(model)
157
- if normalized == "auto":
158
- raise ValueError("auto can select an existing index only; it cannot build a new one")
159
- expected_dim = KNOWN_MODELS[normalized]
160
- if dim is not None and dim != expected_dim:
161
- raise ValueError(
162
- f"Index dimension {dim} does not match {normalized} ({expected_dim})"
163
- )
164
- _active_model_name = normalized
165
- _active_dim = expected_dim
166
-
167
-
168
- def git_files(repo: str) -> list[str]:
169
- out = subprocess.run(
170
- ["git", "ls-files"], cwd=repo, capture_output=True, text=True, check=True
171
- ).stdout.splitlines()
172
- return [
173
- f for f in out
174
- if f.lower().endswith(CODE_EXT) and not f.startswith(EXCLUDE_PREFIXES)
175
- ]
176
-
177
-
178
- def git_head(repo: str) -> str:
179
- return subprocess.run(
180
- ["git", "rev-parse", "--short", "HEAD"],
181
- cwd=repo, capture_output=True, text=True, check=True,
182
- ).stdout.strip()
183
-
184
-
185
- def sha_file(path: str) -> str:
186
- h = hashlib.sha256()
187
- with open(path, "rb") as fh:
188
- for block in iter(lambda: fh.read(1 << 20), b""):
189
- h.update(block)
190
- return h.hexdigest()
191
-
192
-
193
- def chunk_text(text: str) -> list[str]:
194
- step = CHUNK_SIZE - CHUNK_OVERLAP
195
- return [
196
- text[i : i + CHUNK_SIZE]
197
- for i in range(0, len(text), step)
198
- if text[i : i + CHUNK_SIZE].strip()
199
- ]
200
-
201
-
202
- def load_model():
203
- from fastembed import TextEmbedding
204
-
205
- return TextEmbedding(_active_model_name)
206
-
207
-
208
- def collect_chunks(repo: str, files: list[str]):
209
- """Liest Dateien, liefert (texts, meta, file_spans, read_errors)."""
210
- texts, meta, spans, read_errors = [], [], {}, 0
211
- for rel in files:
212
- try:
213
- with open(os.path.join(repo, rel), encoding="utf-8", errors="replace") as fh:
214
- chunks = chunk_text(fh.read())
215
- except OSError:
216
- read_errors += 1
217
- continue
218
- start = len(texts)
219
- for idx, chunk in enumerate(chunks):
220
- texts.append(chunk)
221
- meta.append(f"{rel}#{idx}")
222
- spans[rel] = [start, len(texts)]
223
- return texts, meta, spans, read_errors
224
-
225
-
226
- def embed_into(model, texts: list[str], matrix, offset: int) -> None:
227
- """Streamt Embeddings batchweise DIREKT in die (vorallokierte) Matrix."""
228
- for i in range(0, len(texts), BATCH):
229
- vecs = list(model.embed(texts[i : i + BATCH]))
230
- matrix[offset + i : offset + i + len(vecs)] = np.asarray(
231
- vecs, dtype=np.float32
232
- )
233
- if i % (BATCH * 8) == 0:
234
- print(f"PROGRESS embedded={offset + i}", flush=True)
235
-
236
-
237
- def write_matrix(dirpath: str, total: int):
238
- return np.lib.format.open_memmap(
239
- os.path.join(dirpath, "vectors.npy"),
240
- mode="w+", dtype=np.float32, shape=(total, _active_dim),
241
- )
242
-
243
-
244
- def save_manifest(dirpath: str, repo: str, files_hashes: dict, spans: dict,
245
- meta: list[str], build_s: float) -> None:
246
- manifest = {
247
- "repo": os.path.abspath(repo),
248
- "head": git_head(repo),
249
- "built_at": datetime.now(timezone.utc).isoformat(),
250
- "model": _active_model_name,
251
- "dim": _active_dim,
252
- "chunk_size": CHUNK_SIZE,
253
- "chunk_overlap": CHUNK_OVERLAP,
254
- "chunks": len(meta),
255
- "build_s": round(build_s, 2),
256
- "files": files_hashes,
257
- "spans": spans,
258
- "meta": meta,
259
- }
260
- with open(os.path.join(dirpath, "manifest.json"), "w", encoding="utf-8") as fh:
261
- json.dump(manifest, fh)
262
-
263
-
264
- def load_manifest(dirpath: str) -> dict:
265
- with open(os.path.join(dirpath, "manifest.json"), encoding="utf-8") as fh:
266
- return json.load(fh)
267
-
268
-
269
- def cmd_build(repo: str) -> None:
270
- t0 = time.perf_counter()
271
- files = git_files(repo)
272
- print(f"BUILD files={len(files)} repo={repo}")
273
- texts, meta, spans, read_errors = collect_chunks(repo, files)
274
- print(f"CHUNKS total={len(texts)} read_errors={read_errors}")
275
-
276
- dirpath = index_dir(repo)
277
- os.makedirs(dirpath, exist_ok=True)
278
- matrix = write_matrix(dirpath, len(texts))
279
- model = load_model()
280
- t_embed = time.perf_counter()
281
- embed_into(model, texts, matrix, 0)
282
- matrix.flush()
283
- build_s = time.perf_counter() - t0
284
- embed_s = time.perf_counter() - t_embed
285
-
286
- hashes = {rel: sha_file(os.path.join(repo, rel)) for rel in spans}
287
- save_manifest(dirpath, repo, hashes, spans, meta, build_s)
288
- mb = len(texts) * _active_dim * 4 / 1e6
289
- print(
290
- f"DONE build_s={build_s:.1f} embed_s={embed_s:.1f} chunks={len(texts)} "
291
- f"index_mb={mb:.1f} dir={dirpath}",
292
- flush=True,
293
- )
294
-
295
-
296
- def cmd_update(repo: str) -> None:
297
- """Delta: geaenderte/neue Dateien neu einbetten, geloeschte entfernen."""
298
- t0 = time.perf_counter()
299
- dirpath = index_dir(repo)
300
- man = load_manifest(dirpath)
301
- old_hashes: dict = man["files"]
302
- old_spans: dict = man["spans"]
303
- old_meta: list[str] = man["meta"]
304
- old_mat = np.load(os.path.join(dirpath, "vectors.npy"))
305
-
306
- files = git_files(repo)
307
- texts_new, meta_new, spans_new, read_errors = collect_chunks(repo, files)
308
- new_hashes = {rel: sha_file(os.path.join(repo, rel)) for rel in spans_new}
309
-
310
- changed = {r for r, h in new_hashes.items() if old_hashes.get(r) != h}
311
- deleted = set(old_hashes) - set(new_hashes)
312
- if not changed and not deleted:
313
- print(f"UPDATE noop delta_s={time.perf_counter() - t0:.1f} head={git_head(repo)}")
314
- return
315
-
316
- # Behaltene Dateien: weder geaendert noch geloescht.
317
- kept_rels = [r for r in old_spans if r not in changed and r not in deleted]
318
-
319
- changed_chunks: dict[str, list[str]] = {}
320
- for rel in sorted(changed):
321
- with open(os.path.join(repo, rel), encoding="utf-8", errors="replace") as fh:
322
- changed_chunks[rel] = chunk_text(fh.read())
323
-
324
- total = sum(old_spans[r][1] - old_spans[r][0] for r in kept_rels) + sum(
325
- len(c) for c in changed_chunks.values()
326
- )
327
- matrix = write_matrix(dirpath, total)
328
- model = load_model()
329
-
330
- spans_out, meta_out, cursor = {}, [], 0
331
- for rel in kept_rels:
332
- a, b = old_spans[rel]
333
- matrix[cursor : cursor + (b - a)] = old_mat[a:b]
334
- spans_out[rel] = [cursor, cursor + (b - a)]
335
- meta_out.extend(old_meta[a:b])
336
- cursor += b - a
337
- for rel in sorted(changed_chunks):
338
- chunks = changed_chunks[rel]
339
- embed_into(model, chunks, matrix, cursor)
340
- spans_out[rel] = [cursor, cursor + len(chunks)]
341
- meta_out.extend(f"{rel}#{i}" for i in range(len(chunks)))
342
- cursor += len(chunks)
343
- matrix.flush()
344
-
345
- save_manifest(dirpath, repo, new_hashes, spans_out, meta_out,
346
- time.perf_counter() - t0)
347
- print(
348
- f"UPDATE changed={len(changed)} deleted={len(deleted)} "
349
- f"delta_s={time.perf_counter() - t0:.1f} chunks={total}",
350
- flush=True,
351
- )
352
-
353
-
1
+ #!/usr/bin/env python3
2
+ """codebase-index — lokaler semantischer Index ueber einen Git-Code-Baum.
3
+
4
+ F1-Bau nach Konzept (handoffs/codebase-verstaendnis-lokal-konzept.md),
5
+ Auflagen Dieter 43102:
6
+ - Streaming in vorallokierte float32-Matrix (memmap), KEINE Vektoren-Liste
7
+ (Run-3-Befund: Liste trieb RAM-Spitze auf 10,5 GB).
8
+ - Inkrementell: Content-Hash je Datei im Manifest, Delta statt Vollindex.
9
+ - Query mit Frische-Auskunft + Selbstauskunft in JEDER Antwort.
10
+ - Query-Fehlschlag-Log lokal (query-log.jsonl) — der Moat.
11
+
12
+ Aufruf:
13
+ python codebase_index.py build <repo>
14
+ python codebase_index.py update <repo>
15
+ python codebase_index.py query <repo> "<frage>" [--top 5]
16
+ python codebase_index.py sample <repo>
17
+
18
+ Index-Ort: ~/.blun/codebase-index/<workspace-hash>/ (sichtbar, mit Rueckweg).
19
+ """
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from datetime import datetime, timezone
27
+
28
+ import numpy as np
29
+
30
+ CODE_EXT = (
31
+ ".ts", ".tsx", ".js", ".mjs", ".cjs", ".py", ".html", ".css",
32
+ ".json", ".md", ".toml", ".yml", ".yaml", ".sh", ".ps1", ".sql",
33
+ )
34
+ CHUNK_SIZE = 1000
35
+ CHUNK_OVERLAP = 100
36
+ EMBED_DIM = 384
37
+ BATCH = 256
38
+ MODEL_NAME = "BAAI/bge-small-en-v1.5"
39
+ JINA_CODE_MODEL_NAME = "jinaai/jina-embeddings-v2-base-code"
40
+ KNOWN_MODELS = {
41
+ MODEL_NAME: 384,
42
+ JINA_CODE_MODEL_NAME: 768,
43
+ }
44
+ MODEL_ALIASES = {
45
+ "bge": MODEL_NAME,
46
+ "jina-code": JINA_CODE_MODEL_NAME,
47
+ }
48
+ AUTO_MODEL_ORDER = (JINA_CODE_MODEL_NAME, MODEL_NAME)
49
+
50
+ # Iteration 2 (Dieter 43135): Rausch-Filter — Backup-Kopien und Vendor-
51
+ # Buelle machten 66% aller Index-Rows aus und dominieren Top-5.
52
+ EXCLUDE_PREFIXES = ("backup-", "vendor/")
53
+
54
+ # Aktives Modell + Dim zur Laufzeit (wird per --model ueberschrieben).
55
+ _active_model_name = MODEL_NAME
56
+ _active_dim = EMBED_DIM
57
+
58
+ # Stichprobe V3 (Dieter 43141): Fragen PARAPHRASIERT aus gelesenem
59
+ # Dateiinhalt — keine wörtlichen Bezeichner/Funktionsnamen/Kommentare aus
60
+ # den Dateien (misst semantische Suche, nicht Wortgleichheit).
61
+ # api-chat.js: routet Chat-Nachrichten ans Modell, Fallback-Kette, Stream.
62
+ # terminal-manager.js: startet Shell-Prozesse mit Sandbox-/Approval-Modi.
63
+ # renderer-view-shell.js: wechselt Login-/Willkommens-/Chat-Ansicht.
64
+ SAMPLE_QUERIES = [
65
+ ("how are conversation messages routed to the right model with a fallback chain", "api-chat"),
66
+ ("starting sandboxed shell processes with approval modes and output history", "terminal-manager"),
67
+ ("switching between sign-in screen, welcome page and main conversation view", "renderer-view-shell"),
68
+ ]
69
+
70
+ SELSTAUSKUNFT = (
71
+ "HINWEIS: semantischer Index, kann falsch liegen — Fundstelle vor "
72
+ "Verwendung in der Datei verifizieren."
73
+ )
74
+
75
+
76
+ def canonical_repo_path(repo: str) -> str:
77
+ return os.path.normcase(os.path.realpath(os.path.abspath(repo)))
78
+
79
+
80
+ def index_root() -> str:
81
+ return os.path.join(os.path.expanduser("~"), ".blun", "codebase-index")
82
+
83
+
84
+ def index_dir(repo: str) -> str:
85
+ raw = canonical_repo_path(repo) + "|" + _active_model_name
86
+ key = hashlib.sha256(raw.encode()).hexdigest()[:12]
87
+ return os.path.join(index_root(), key)
88
+
89
+
90
+ def normalize_model_selector(selector: str) -> str:
91
+ if selector == "auto":
92
+ return selector
93
+ model = MODEL_ALIASES.get(selector, selector)
94
+ if model not in KNOWN_MODELS:
95
+ allowed = ", ".join(("auto", *MODEL_ALIASES, *KNOWN_MODELS))
96
+ raise ValueError(f"Unsupported index model {selector!r}; choose one of: {allowed}")
97
+ return model
98
+
99
+
100
+ def find_existing_indexes(repo: str) -> list[tuple[str, dict]]:
101
+ root = index_root()
102
+ if not os.path.isdir(root):
103
+ return []
104
+ canonical_repo = canonical_repo_path(repo)
105
+ canonical_root = os.path.realpath(root)
106
+ candidates = []
107
+ for entry in os.scandir(root):
108
+ if not entry.is_dir(follow_symlinks=False):
109
+ continue
110
+ directory = os.path.realpath(entry.path)
111
+ try:
112
+ if os.path.commonpath((canonical_root, directory)) != canonical_root:
113
+ continue
114
+ except ValueError:
115
+ continue
116
+ manifest_path = os.path.join(directory, "manifest.json")
117
+ vectors_path = os.path.join(directory, "vectors.npy")
118
+ if not os.path.isfile(manifest_path) or not os.path.isfile(vectors_path):
119
+ continue
120
+ try:
121
+ manifest = load_manifest(directory)
122
+ except (OSError, ValueError, TypeError):
123
+ continue
124
+ model = manifest.get("model")
125
+ if model not in KNOWN_MODELS or manifest.get("dim") != KNOWN_MODELS[model]:
126
+ continue
127
+ manifest_repo = manifest.get("repo")
128
+ if not isinstance(manifest_repo, str):
129
+ continue
130
+ if canonical_repo_path(manifest_repo) != canonical_repo:
131
+ continue
132
+ candidates.append((directory, manifest))
133
+ return sorted(candidates, key=lambda item: item[0])
134
+
135
+
136
+ def resolve_existing_index(repo: str, selector: str = "auto") -> tuple[str, dict]:
137
+ requested = normalize_model_selector(selector)
138
+ candidates = find_existing_indexes(repo)
139
+ model_order = AUTO_MODEL_ORDER if requested == "auto" else (requested,)
140
+ for model in model_order:
141
+ matches = [item for item in candidates if item[1]["model"] == model]
142
+ if matches:
143
+ return max(
144
+ matches,
145
+ key=lambda item: (str(item[1].get("built_at", "")), item[0]),
146
+ )
147
+ available = sorted({item[1]["model"] for item in candidates})
148
+ suffix = f"; available for this repository: {', '.join(available)}" if available else ""
149
+ raise FileNotFoundError(
150
+ f"No compatible codebase index for {os.path.abspath(repo)!r} and selector {selector!r}{suffix}"
151
+ )
152
+
153
+
154
+ def activate_model(model: str, dim: int | None = None) -> None:
155
+ global _active_model_name, _active_dim
156
+ normalized = normalize_model_selector(model)
157
+ if normalized == "auto":
158
+ raise ValueError("auto can select an existing index only; it cannot build a new one")
159
+ expected_dim = KNOWN_MODELS[normalized]
160
+ if dim is not None and dim != expected_dim:
161
+ raise ValueError(
162
+ f"Index dimension {dim} does not match {normalized} ({expected_dim})"
163
+ )
164
+ _active_model_name = normalized
165
+ _active_dim = expected_dim
166
+
167
+
168
+ def git_files(repo: str) -> list[str]:
169
+ out = subprocess.run(
170
+ ["git", "ls-files"], cwd=repo, capture_output=True, text=True, check=True
171
+ ).stdout.splitlines()
172
+ return [
173
+ f for f in out
174
+ if f.lower().endswith(CODE_EXT) and not f.startswith(EXCLUDE_PREFIXES)
175
+ ]
176
+
177
+
178
+ def git_head(repo: str) -> str:
179
+ return subprocess.run(
180
+ ["git", "rev-parse", "--short", "HEAD"],
181
+ cwd=repo, capture_output=True, text=True, check=True,
182
+ ).stdout.strip()
183
+
184
+
185
+ def sha_file(path: str) -> str:
186
+ h = hashlib.sha256()
187
+ with open(path, "rb") as fh:
188
+ for block in iter(lambda: fh.read(1 << 20), b""):
189
+ h.update(block)
190
+ return h.hexdigest()
191
+
192
+
193
+ def chunk_text(text: str) -> list[str]:
194
+ step = CHUNK_SIZE - CHUNK_OVERLAP
195
+ return [
196
+ text[i : i + CHUNK_SIZE]
197
+ for i in range(0, len(text), step)
198
+ if text[i : i + CHUNK_SIZE].strip()
199
+ ]
200
+
201
+
202
+ def load_model():
203
+ from fastembed import TextEmbedding
204
+
205
+ return TextEmbedding(_active_model_name)
206
+
207
+
208
+ def collect_chunks(repo: str, files: list[str]):
209
+ """Liest Dateien, liefert (texts, meta, file_spans, read_errors)."""
210
+ texts, meta, spans, read_errors = [], [], {}, 0
211
+ for rel in files:
212
+ try:
213
+ with open(os.path.join(repo, rel), encoding="utf-8", errors="replace") as fh:
214
+ chunks = chunk_text(fh.read())
215
+ except OSError:
216
+ read_errors += 1
217
+ continue
218
+ start = len(texts)
219
+ for idx, chunk in enumerate(chunks):
220
+ texts.append(chunk)
221
+ meta.append(f"{rel}#{idx}")
222
+ spans[rel] = [start, len(texts)]
223
+ return texts, meta, spans, read_errors
224
+
225
+
226
+ def embed_into(model, texts: list[str], matrix, offset: int) -> None:
227
+ """Streamt Embeddings batchweise DIREKT in die (vorallokierte) Matrix."""
228
+ for i in range(0, len(texts), BATCH):
229
+ vecs = list(model.embed(texts[i : i + BATCH]))
230
+ matrix[offset + i : offset + i + len(vecs)] = np.asarray(
231
+ vecs, dtype=np.float32
232
+ )
233
+ if i % (BATCH * 8) == 0:
234
+ print(f"PROGRESS embedded={offset + i}", flush=True)
235
+
236
+
237
+ def write_matrix(dirpath: str, total: int):
238
+ return np.lib.format.open_memmap(
239
+ os.path.join(dirpath, "vectors.npy"),
240
+ mode="w+", dtype=np.float32, shape=(total, _active_dim),
241
+ )
242
+
243
+
244
+ def save_manifest(dirpath: str, repo: str, files_hashes: dict, spans: dict,
245
+ meta: list[str], build_s: float) -> None:
246
+ manifest = {
247
+ "repo": os.path.abspath(repo),
248
+ "head": git_head(repo),
249
+ "built_at": datetime.now(timezone.utc).isoformat(),
250
+ "model": _active_model_name,
251
+ "dim": _active_dim,
252
+ "chunk_size": CHUNK_SIZE,
253
+ "chunk_overlap": CHUNK_OVERLAP,
254
+ "chunks": len(meta),
255
+ "build_s": round(build_s, 2),
256
+ "files": files_hashes,
257
+ "spans": spans,
258
+ "meta": meta,
259
+ }
260
+ with open(os.path.join(dirpath, "manifest.json"), "w", encoding="utf-8") as fh:
261
+ json.dump(manifest, fh)
262
+
263
+
264
+ def load_manifest(dirpath: str) -> dict:
265
+ with open(os.path.join(dirpath, "manifest.json"), encoding="utf-8") as fh:
266
+ return json.load(fh)
267
+
268
+
269
+ def cmd_build(repo: str) -> None:
270
+ t0 = time.perf_counter()
271
+ files = git_files(repo)
272
+ print(f"BUILD files={len(files)} repo={repo}")
273
+ texts, meta, spans, read_errors = collect_chunks(repo, files)
274
+ print(f"CHUNKS total={len(texts)} read_errors={read_errors}")
275
+
276
+ dirpath = index_dir(repo)
277
+ os.makedirs(dirpath, exist_ok=True)
278
+ matrix = write_matrix(dirpath, len(texts))
279
+ model = load_model()
280
+ t_embed = time.perf_counter()
281
+ embed_into(model, texts, matrix, 0)
282
+ matrix.flush()
283
+ build_s = time.perf_counter() - t0
284
+ embed_s = time.perf_counter() - t_embed
285
+
286
+ hashes = {rel: sha_file(os.path.join(repo, rel)) for rel in spans}
287
+ save_manifest(dirpath, repo, hashes, spans, meta, build_s)
288
+ mb = len(texts) * _active_dim * 4 / 1e6
289
+ print(
290
+ f"DONE build_s={build_s:.1f} embed_s={embed_s:.1f} chunks={len(texts)} "
291
+ f"index_mb={mb:.1f} dir={dirpath}",
292
+ flush=True,
293
+ )
294
+
295
+
296
+ def cmd_update(repo: str) -> None:
297
+ """Delta: geaenderte/neue Dateien neu einbetten, geloeschte entfernen."""
298
+ t0 = time.perf_counter()
299
+ dirpath = index_dir(repo)
300
+ man = load_manifest(dirpath)
301
+ old_hashes: dict = man["files"]
302
+ old_spans: dict = man["spans"]
303
+ old_meta: list[str] = man["meta"]
304
+ old_mat = np.load(os.path.join(dirpath, "vectors.npy"))
305
+
306
+ files = git_files(repo)
307
+ texts_new, meta_new, spans_new, read_errors = collect_chunks(repo, files)
308
+ new_hashes = {rel: sha_file(os.path.join(repo, rel)) for rel in spans_new}
309
+
310
+ changed = {r for r, h in new_hashes.items() if old_hashes.get(r) != h}
311
+ deleted = set(old_hashes) - set(new_hashes)
312
+ if not changed and not deleted:
313
+ print(f"UPDATE noop delta_s={time.perf_counter() - t0:.1f} head={git_head(repo)}")
314
+ return
315
+
316
+ # Behaltene Dateien: weder geaendert noch geloescht.
317
+ kept_rels = [r for r in old_spans if r not in changed and r not in deleted]
318
+
319
+ changed_chunks: dict[str, list[str]] = {}
320
+ for rel in sorted(changed):
321
+ with open(os.path.join(repo, rel), encoding="utf-8", errors="replace") as fh:
322
+ changed_chunks[rel] = chunk_text(fh.read())
323
+
324
+ total = sum(old_spans[r][1] - old_spans[r][0] for r in kept_rels) + sum(
325
+ len(c) for c in changed_chunks.values()
326
+ )
327
+ matrix = write_matrix(dirpath, total)
328
+ model = load_model()
329
+
330
+ spans_out, meta_out, cursor = {}, [], 0
331
+ for rel in kept_rels:
332
+ a, b = old_spans[rel]
333
+ matrix[cursor : cursor + (b - a)] = old_mat[a:b]
334
+ spans_out[rel] = [cursor, cursor + (b - a)]
335
+ meta_out.extend(old_meta[a:b])
336
+ cursor += b - a
337
+ for rel in sorted(changed_chunks):
338
+ chunks = changed_chunks[rel]
339
+ embed_into(model, chunks, matrix, cursor)
340
+ spans_out[rel] = [cursor, cursor + len(chunks)]
341
+ meta_out.extend(f"{rel}#{i}" for i in range(len(chunks)))
342
+ cursor += len(chunks)
343
+ matrix.flush()
344
+
345
+ save_manifest(dirpath, repo, new_hashes, spans_out, meta_out,
346
+ time.perf_counter() - t0)
347
+ print(
348
+ f"UPDATE changed={len(changed)} deleted={len(deleted)} "
349
+ f"delta_s={time.perf_counter() - t0:.1f} chunks={total}",
350
+ flush=True,
351
+ )
352
+
353
+
354
354
  def freshness(repo: str, man: dict) -> str:
355
355
  current = git_head(repo)
356
356
  same = "== HEAD" if current == man["head"] else f"Index {man['head']} != HEAD {current} — 'update' faellig"
357
357
  return f"Index-Stand: {man['head']} ({man['built_at']}), {man['chunks']} Chunks | {same}"
358
358
 
359
359
 
360
- def append_query_metadata(dirpath: str, question: str, hits: list[tuple[str, float]],
361
- requested_top: int, latency_ms: float, man: dict) -> None:
362
- record = {
363
- "ts": datetime.now(timezone.utc).isoformat(),
364
- "query_bytes": len(question.encode("utf-8")),
365
- "requested_top": requested_top,
366
- "result_count": len(hits),
367
- "result_paths": list(dict.fromkeys(meta.rsplit("#", 1)[0] for meta, _ in hits)),
368
- "query_ms": round(latency_ms, 1),
369
- "index_head": man["head"],
370
- "model": man["model"],
371
- }
372
- with open(os.path.join(dirpath, "query-log.jsonl"), "a", encoding="utf-8") as fh:
373
- fh.write(json.dumps(record) + "\n")
360
+ def cmd_query(repo: str, question: str, top: int, selector: str = "auto") -> int:
361
+ dirpath, man = resolve_existing_index(repo, selector)
362
+ activate_model(man["model"], man["dim"])
363
+ mat = np.load(os.path.join(dirpath, "vectors.npy"))
364
+ norms = np.linalg.norm(mat, axis=1, keepdims=True)
365
+ mat_n = mat / np.maximum(norms, 1e-12)
374
366
 
367
+ model = load_model()
368
+ t0 = time.perf_counter()
369
+ qv = np.asarray(list(model.embed([question])), dtype=np.float32)[0]
370
+ qv = qv / max(np.linalg.norm(qv), 1e-12)
371
+ scores = mat_n @ qv
372
+ idx = np.argsort(scores)[::-1][:top]
373
+ latency_ms = (time.perf_counter() - t0) * 1000
375
374
 
376
- def cmd_query(repo: str, question: str, top: int, selector: str = "auto") -> int:
377
- dirpath, man = resolve_existing_index(repo, selector)
378
- activate_model(man["model"], man["dim"])
379
- mat = np.load(os.path.join(dirpath, "vectors.npy"))
380
- norms = np.linalg.norm(mat, axis=1, keepdims=True)
381
- mat_n = mat / np.maximum(norms, 1e-12)
382
-
383
- model = load_model()
384
- t0 = time.perf_counter()
385
- qv = np.asarray(list(model.embed([question])), dtype=np.float32)[0]
386
- qv = qv / max(np.linalg.norm(qv), 1e-12)
387
- scores = mat_n @ qv
388
- idx = np.argsort(scores)[::-1][:top]
389
- latency_ms = (time.perf_counter() - t0) * 1000
390
-
391
- hits = [(man["meta"][i], round(float(scores[i]), 4)) for i in idx]
392
- print(SELSTAUSKUNFT)
393
- print(f"Index-Modell: {man['model']}")
394
- print(freshness(repo, man))
395
- for m, s in hits:
396
- print(f" {s:.4f} {m}")
397
- print(f"query_ms={latency_ms:.1f}")
398
-
399
- append_query_metadata(dirpath, question, hits, top, latency_ms, man)
400
- return 0
401
-
402
-
403
- def cmd_sample(repo: str, selector: str = "auto") -> int:
404
- """Gate: Stichprobe aus dem Subjekt-Baum, Ziel >= 2/3 in Top-5."""
405
- dirpath, man = resolve_existing_index(repo, selector)
406
- activate_model(man["model"], man["dim"])
407
- mat = np.load(os.path.join(dirpath, "vectors.npy"))
408
- norms = np.linalg.norm(mat, axis=1, keepdims=True)
409
- mat_n = mat / np.maximum(norms, 1e-12)
410
- model = load_model()
411
-
412
- hits_count = 0
413
- for question, expected in SAMPLE_QUERIES:
414
- qv = np.asarray(list(model.embed([question])), dtype=np.float32)[0]
415
- qv = qv / max(np.linalg.norm(qv), 1e-12)
416
- scores = mat_n @ qv
417
- idx = np.argsort(scores)[::-1][:5]
418
- hits = [(man["meta"][i], round(float(scores[i]), 4)) for i in idx]
419
- ok = any(expected in m for m, _ in hits)
420
- hits_count += ok
421
- print(f"SAMPLE {'HIT ' if ok else 'MISS'} {question!r} expected={expected}")
422
- for m, s in hits:
423
- print(f" {s:.4f} {m}")
424
- verdict = "PASS" if hits_count >= 2 else "FAIL"
425
- print(f"SAMPLE_RESULT {hits_count}/3 gate=2/3 -> {verdict}")
426
- return 0 if hits_count >= 2 else 1
427
-
428
-
429
- def main() -> int:
430
- global _active_model_name, _active_dim
431
- if len(sys.argv) < 3:
432
- print(__doc__)
433
- return 2
434
- cmd, repo = sys.argv[1], sys.argv[2]
435
- selector = sys.argv[sys.argv.index("--model") + 1] if "--model" in sys.argv else (
436
- "auto" if cmd in ("query", "sample") else MODEL_NAME
437
- )
438
- if cmd in ("build", "update"):
439
- try:
440
- activate_model(selector)
441
- except ValueError as error:
442
- print(f"MODEL_ERROR {error}")
443
- return 2
444
- from fastembed import TextEmbedding
445
-
446
- dims = {m["model"]: m.get("dim") for m in TextEmbedding.list_supported_models()}
447
- if _active_model_name not in dims:
448
- print(f"MODEL_UNKNOWN {_active_model_name} — nicht im fastembed-Angebot")
449
- return 2
450
- _active_dim = int(dims[_active_model_name])
451
- print(f"MODEL {_active_model_name} dim={_active_dim}")
452
- if cmd == "build":
453
- cmd_build(repo)
454
- return 0
455
- if cmd == "update":
456
- cmd_update(repo)
457
- return 0
458
- if cmd == "query":
459
- top = 5
460
- if "--top" in sys.argv:
461
- top = int(sys.argv[sys.argv.index("--top") + 1])
462
- try:
463
- return cmd_query(repo, sys.argv[3], top, selector)
464
- except (FileNotFoundError, ValueError) as error:
465
- print(f"INDEX_ERROR {error}")
466
- return 2
467
- if cmd == "sample":
468
- try:
469
- return cmd_sample(repo, selector)
470
- except (FileNotFoundError, ValueError) as error:
471
- print(f"INDEX_ERROR {error}")
472
- return 2
473
- print(f"unknown command: {cmd}")
474
- return 2
475
-
476
-
477
- if __name__ == "__main__":
478
- sys.exit(main())
375
+ hits = [(man["meta"][i], round(float(scores[i]), 4)) for i in idx]
376
+ print(SELSTAUSKUNFT)
377
+ print(f"Index-Modell: {man['model']}")
378
+ print(freshness(repo, man))
379
+ for m, s in hits:
380
+ print(f" {s:.4f} {m}")
381
+ print(f"query_ms={latency_ms:.1f}")
382
+
383
+ log_path = os.path.join(dirpath, "query-log.jsonl")
384
+ with open(log_path, "a", encoding="utf-8") as fh:
385
+ fh.write(json.dumps({
386
+ "ts": datetime.now(timezone.utc).isoformat(),
387
+ "query": question,
388
+ "top": hits,
389
+ "query_ms": round(latency_ms, 1),
390
+ "index_head": man["head"],
391
+ }) + "\n")
392
+ return 0
393
+
394
+
395
+ def cmd_sample(repo: str, selector: str = "auto") -> int:
396
+ """Gate: Stichprobe aus dem Subjekt-Baum, Ziel >= 2/3 in Top-5."""
397
+ dirpath, man = resolve_existing_index(repo, selector)
398
+ activate_model(man["model"], man["dim"])
399
+ mat = np.load(os.path.join(dirpath, "vectors.npy"))
400
+ norms = np.linalg.norm(mat, axis=1, keepdims=True)
401
+ mat_n = mat / np.maximum(norms, 1e-12)
402
+ model = load_model()
403
+
404
+ hits_count = 0
405
+ for question, expected in SAMPLE_QUERIES:
406
+ qv = np.asarray(list(model.embed([question])), dtype=np.float32)[0]
407
+ qv = qv / max(np.linalg.norm(qv), 1e-12)
408
+ scores = mat_n @ qv
409
+ idx = np.argsort(scores)[::-1][:5]
410
+ hits = [(man["meta"][i], round(float(scores[i]), 4)) for i in idx]
411
+ ok = any(expected in m for m, _ in hits)
412
+ hits_count += ok
413
+ print(f"SAMPLE {'HIT ' if ok else 'MISS'} {question!r} expected={expected}")
414
+ for m, s in hits:
415
+ print(f" {s:.4f} {m}")
416
+ verdict = "PASS" if hits_count >= 2 else "FAIL"
417
+ print(f"SAMPLE_RESULT {hits_count}/3 gate=2/3 -> {verdict}")
418
+ return 0 if hits_count >= 2 else 1
419
+
420
+
421
+ def main() -> int:
422
+ global _active_model_name, _active_dim
423
+ if len(sys.argv) < 3:
424
+ print(__doc__)
425
+ return 2
426
+ cmd, repo = sys.argv[1], sys.argv[2]
427
+ selector = sys.argv[sys.argv.index("--model") + 1] if "--model" in sys.argv else (
428
+ "auto" if cmd in ("query", "sample") else MODEL_NAME
429
+ )
430
+ if cmd in ("build", "update"):
431
+ try:
432
+ activate_model(selector)
433
+ except ValueError as error:
434
+ print(f"MODEL_ERROR {error}")
435
+ return 2
436
+ from fastembed import TextEmbedding
437
+
438
+ dims = {m["model"]: m.get("dim") for m in TextEmbedding.list_supported_models()}
439
+ if _active_model_name not in dims:
440
+ print(f"MODEL_UNKNOWN {_active_model_name} — nicht im fastembed-Angebot")
441
+ return 2
442
+ _active_dim = int(dims[_active_model_name])
443
+ print(f"MODEL {_active_model_name} dim={_active_dim}")
444
+ if cmd == "build":
445
+ cmd_build(repo)
446
+ return 0
447
+ if cmd == "update":
448
+ cmd_update(repo)
449
+ return 0
450
+ if cmd == "query":
451
+ top = 5
452
+ if "--top" in sys.argv:
453
+ top = int(sys.argv[sys.argv.index("--top") + 1])
454
+ try:
455
+ return cmd_query(repo, sys.argv[3], top, selector)
456
+ except (FileNotFoundError, ValueError) as error:
457
+ print(f"INDEX_ERROR {error}")
458
+ return 2
459
+ if cmd == "sample":
460
+ try:
461
+ return cmd_sample(repo, selector)
462
+ except (FileNotFoundError, ValueError) as error:
463
+ print(f"INDEX_ERROR {error}")
464
+ return 2
465
+ print(f"unknown command: {cmd}")
466
+ return 2
467
+
468
+
469
+ if __name__ == "__main__":
470
+ sys.exit(main())