arkaos 4.0.2 → 4.1.1

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 (214) hide show
  1. package/README.md +2 -2
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +16 -22
  4. package/arka/skills/flow/SKILL.md +108 -234
  5. package/config/claude-agents/eduardo-copy.md +53 -0
  6. package/config/claude-agents/francisca-tech.md +52 -0
  7. package/config/claude-agents/marta-cqo.md +52 -0
  8. package/config/claude-agents/paulo-tech-lead.md +50 -0
  9. package/config/cognition/schedules.yaml +15 -0
  10. package/config/constitution.yaml +17 -21
  11. package/config/hooks/_lib/workflow-classifier.sh +1 -1
  12. package/config/hooks/post-tool-use.sh +52 -536
  13. package/config/hooks/pre-tool-use.sh +46 -280
  14. package/config/hooks/session-start.sh +8 -10
  15. package/config/hooks/stop.sh +44 -424
  16. package/config/hooks/user-prompt-submit.sh +55 -457
  17. package/core/__pycache__/__init__.cpython-314.pyc +0 -0
  18. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  19. package/core/cognition/dreaming.py +5 -0
  20. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  21. package/core/cognition/scheduler/daemon.py +10 -0
  22. package/core/forge/__init__.py +2 -0
  23. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  24. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  25. package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
  26. package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
  27. package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
  28. package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
  29. package/core/forge/orchestrator.py +50 -27
  30. package/core/forge/persistence.py +16 -0
  31. package/core/forge/renderer.py +16 -0
  32. package/core/forge/runtime_dispatcher.py +34 -19
  33. package/core/forge/schema.py +12 -0
  34. package/core/governance/__pycache__/__init__.cpython-312.pyc +0 -0
  35. package/core/governance/__pycache__/__init__.cpython-314.pyc +0 -0
  36. package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
  37. package/core/governance/__pycache__/closing_marker_check.cpython-314.pyc +0 -0
  38. package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
  39. package/core/governance/__pycache__/constitution.cpython-312.pyc +0 -0
  40. package/core/governance/__pycache__/constitution.cpython-314.pyc +0 -0
  41. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  42. package/core/governance/__pycache__/kb_cite_check.cpython-314.pyc +0 -0
  43. package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
  44. package/core/governance/__pycache__/meta_tag_check.cpython-314.pyc +0 -0
  45. package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
  46. package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
  47. package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
  48. package/core/governance/__pycache__/skill_proposer.cpython-314.pyc +0 -0
  49. package/core/governance/__pycache__/sycophancy_detector.cpython-314.pyc +0 -0
  50. package/core/governance/closing_marker_check.py +9 -5
  51. package/core/governance/compliance_telemetry.py +1 -1
  52. package/core/governance/compliance_telemetry_cli.py +1 -1
  53. package/core/governance/evidence_checks.py +421 -0
  54. package/core/governance/meta_tag_check.py +36 -0
  55. package/core/governance/qg_verdict.py +78 -0
  56. package/core/governance/review_workflow.py +34 -1
  57. package/core/governance/skill_proposer.py +2 -1
  58. package/core/hooks/__init__.py +12 -0
  59. package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
  60. package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  61. package/core/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
  62. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  63. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  64. package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
  65. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  66. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  67. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  68. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  69. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  70. package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
  71. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  72. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  73. package/core/hooks/__pycache__/user_prompt_submit.cpython-312.pyc +0 -0
  74. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  75. package/core/hooks/_shared.py +110 -0
  76. package/core/hooks/post_tool_use.py +689 -0
  77. package/core/hooks/pre_tool_use.py +267 -0
  78. package/core/hooks/stop.py +395 -0
  79. package/core/hooks/user_prompt_submit.py +600 -0
  80. package/core/jobs/__pycache__/__init__.cpython-312.pyc +0 -0
  81. package/core/jobs/__pycache__/__init__.cpython-314.pyc +0 -0
  82. package/core/jobs/__pycache__/auto_doc_worker.cpython-312.pyc +0 -0
  83. package/core/jobs/__pycache__/auto_doc_worker.cpython-314.pyc +0 -0
  84. package/core/jobs/__pycache__/manager.cpython-312.pyc +0 -0
  85. package/core/jobs/__pycache__/manager.cpython-314.pyc +0 -0
  86. package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
  87. package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
  88. package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
  89. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  90. package/core/knowledge/embedder.py +118 -11
  91. package/core/knowledge/vector_store.py +111 -14
  92. package/core/memory/__pycache__/session_store.cpython-314.pyc +0 -0
  93. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  94. package/core/obsidian/relator.py +18 -8
  95. package/core/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  96. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  97. package/core/runtime/__pycache__/__init__.cpython-314.pyc +0 -0
  98. package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
  99. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  100. package/core/runtime/__pycache__/base.cpython-314.pyc +0 -0
  101. package/core/runtime/__pycache__/capabilities_cli.cpython-312.pyc +0 -0
  102. package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
  103. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  104. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  105. package/core/runtime/__pycache__/claude_code.cpython-314.pyc +0 -0
  106. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  107. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  108. package/core/runtime/__pycache__/codex_cli.cpython-314.pyc +0 -0
  109. package/core/runtime/__pycache__/cost_governor.cpython-312.pyc +0 -0
  110. package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
  111. package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
  112. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  113. package/core/runtime/__pycache__/cursor.cpython-314.pyc +0 -0
  114. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  115. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  116. package/core/runtime/__pycache__/gemini_cli.cpython-314.pyc +0 -0
  117. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-314.pyc +0 -0
  118. package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
  119. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  120. package/core/runtime/__pycache__/llm_provider.cpython-314.pyc +0 -0
  121. package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
  122. package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
  123. package/core/runtime/__pycache__/llm_retry.cpython-314.pyc +0 -0
  124. package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
  125. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  126. package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
  127. package/core/runtime/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  128. package/core/runtime/__pycache__/path_resolver.cpython-312.pyc +0 -0
  129. package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
  130. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  131. package/core/runtime/__pycache__/pricing.cpython-314.pyc +0 -0
  132. package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
  133. package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
  134. package/core/runtime/__pycache__/registry.cpython-314.pyc +0 -0
  135. package/core/runtime/__pycache__/squad_orchestrator.cpython-312.pyc +0 -0
  136. package/core/runtime/base.py +29 -0
  137. package/core/runtime/capabilities_cli.py +74 -0
  138. package/core/runtime/claude_code.py +8 -0
  139. package/core/runtime/codex_cli.py +205 -37
  140. package/core/runtime/cost_governor.py +184 -0
  141. package/core/runtime/cursor.py +8 -0
  142. package/core/runtime/gemini_cli.py +8 -0
  143. package/core/runtime/llm_provider.py +32 -13
  144. package/core/runtime/llm_retry.py +233 -0
  145. package/core/runtime/native_usage.py +186 -0
  146. package/core/runtime/pricing.py +9 -0
  147. package/core/runtime/registry.py +3 -0
  148. package/core/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  149. package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
  150. package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  151. package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
  152. package/core/shared/test_evidence.py +34 -0
  153. package/core/synapse/__pycache__/__init__.cpython-314.pyc +0 -0
  154. package/core/synapse/__pycache__/agent_experiences_layer.cpython-312.pyc +0 -0
  155. package/core/synapse/__pycache__/cache.cpython-314.pyc +0 -0
  156. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  157. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  158. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  159. package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
  160. package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
  161. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  162. package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
  163. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  164. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  165. package/core/synapse/__pycache__/layers.cpython-314.pyc +0 -0
  166. package/core/synapse/engine.py +7 -0
  167. package/core/synapse/graph_context_layer.py +212 -0
  168. package/core/synapse/kb_cache.py +3 -0
  169. package/core/synapse/layers.py +104 -20
  170. package/core/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  171. package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
  172. package/core/workflow/__pycache__/engine.cpython-314.pyc +0 -0
  173. package/core/workflow/__pycache__/flow_enforcer.cpython-312.pyc +0 -0
  174. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  175. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  176. package/core/workflow/__pycache__/gate_checkpoint.cpython-312.pyc +0 -0
  177. package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
  178. package/core/workflow/__pycache__/gate_checkpoint.cpython-314.pyc +0 -0
  179. package/core/workflow/__pycache__/loader.cpython-314.pyc +0 -0
  180. package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
  181. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  182. package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
  183. package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
  184. package/core/workflow/__pycache__/schema.cpython-314.pyc +0 -0
  185. package/core/workflow/__pycache__/specialist_enforcer.cpython-312.pyc +0 -0
  186. package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
  187. package/core/workflow/__pycache__/state.cpython-314.pyc +0 -0
  188. package/core/workflow/enforcer.py +5 -16
  189. package/core/workflow/flow_enforcer.py +36 -11
  190. package/core/workflow/gate_checkpoint.py +149 -0
  191. package/core/workflow/research_gate.py +15 -3
  192. package/core/workflow/rules_registry.py +137 -389
  193. package/core/workflow/specialist_enforcer.py +21 -4
  194. package/dashboard/app/pages/knowledge/index.vue +4 -1
  195. package/departments/ops/skills/n8n-flow/SKILL.md +13 -0
  196. package/departments/ops/skills/workflow-automate/SKILL.md +16 -0
  197. package/departments/quality/SKILL.md +45 -5
  198. package/departments/quality/agents/copy-director.yaml +3 -1
  199. package/departments/quality/agents/tech-director.yaml +3 -1
  200. package/installer/adapters/claude-code.js +46 -2
  201. package/installer/config-seed.js +39 -14
  202. package/installer/doctor.js +8 -0
  203. package/installer/graphify.js +153 -0
  204. package/installer/index.js +20 -0
  205. package/installer/init.js +11 -0
  206. package/installer/update.js +19 -0
  207. package/package.json +1 -1
  208. package/pyproject.toml +3 -1
  209. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  210. package/scripts/__pycache__/knowledge-index.cpython-313.pyc +0 -0
  211. package/scripts/__pycache__/synapse-bridge.cpython-312.pyc +0 -0
  212. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  213. package/scripts/knowledge-index.py +8 -2
  214. package/scripts/synapse-bridge.py +102 -26
@@ -1,29 +1,136 @@
1
1
  """Embedding wrapper — local embeddings via fastembed.
2
2
 
3
3
  Graceful degradation: if fastembed is not installed, returns None
4
- and the vector store falls back to keyword matching.
4
+ and the vector store falls back to keyword matching (which the store
5
+ now labels as ``retrieval: "keyword-degraded"`` — see vector_store.py).
6
+
7
+ Model selection (PR-3 v4.1 — RAG honesty):
8
+ 1. ``ARKA_EMBED_MODEL`` env var
9
+ 2. ``knowledge.embedModel`` in ``~/.arkaos/config.json``
10
+ 3. default ``BAAI/bge-small-en-v1.5`` (backward compatible, 384 dims)
11
+
12
+ Recommendation for PT-heavy corpora:
13
+ ``sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2``
14
+ (384 dims — same dimension as the default, so switching does NOT
15
+ require re-creating the vec0 table). ``intfloat/multilingual-e5-small``
16
+ is NOT in fastembed's supported model list (fastembed only ships the
17
+ ``intfloat/multilingual-e5-large`` variant, 1024 dims, which WOULD
18
+ force a re-index); the MiniLM multilingual model is the supported
19
+ drop-in. Verify with ``TextEmbedding.list_supported_models()``.
20
+
21
+ Dimensions are derived from the model (``embedding_dims()``), never
22
+ hardcoded at call sites. The vector store guards against a stored vec0
23
+ table whose dimension differs from the configured model — it logs and
24
+ keeps the stored dimension instead of corrupting the table.
5
25
  """
6
26
 
27
+ import json
28
+ import logging
29
+ import os
30
+ from pathlib import Path
7
31
  from typing import Optional
8
32
 
9
- # Lazy import — fastembed is optional
10
- _model = None
11
- _model_name = "BAAI/bge-small-en-v1.5" # 384 dims, fast, good quality
33
+ logger = logging.getLogger(__name__)
34
+
35
+ DEFAULT_MODEL = "BAAI/bge-small-en-v1.5" # 384 dims, fast, good quality
36
+ RECOMMENDED_MULTILINGUAL_MODEL = (
37
+ "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
38
+ )
39
+
40
+ # Known dims for models we document — used when fastembed is missing so
41
+ # dimension resolution stays deterministic in degraded environments.
42
+ _KNOWN_DIMS: dict[str, int] = {
43
+ "BAAI/bge-small-en-v1.5": 384,
44
+ "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2": 384,
45
+ "sentence-transformers/paraphrase-multilingual-mpnet-base-v2": 768,
46
+ "intfloat/multilingual-e5-large": 1024,
47
+ }
48
+
49
+ # Legacy constant (dims of the default model). Prefer embedding_dims().
12
50
  EMBEDDING_DIMS = 384
13
51
 
52
+ # Lazy singletons — fastembed is optional.
53
+ _model = None
54
+ _loaded_model_name: Optional[str] = None
55
+
56
+
57
+ def get_model_name() -> str:
58
+ """Resolve the configured embedding model name (env > config > default)."""
59
+ env_name = os.environ.get("ARKA_EMBED_MODEL", "").strip()
60
+ if env_name:
61
+ return env_name
62
+ config_path = Path.home() / ".arkaos" / "config.json"
63
+ try:
64
+ data = json.loads(config_path.read_text(encoding="utf-8"))
65
+ name = (data.get("knowledge") or {}).get("embedModel", "")
66
+ if isinstance(name, str) and name.strip():
67
+ return name.strip()
68
+ except (OSError, json.JSONDecodeError, AttributeError):
69
+ pass
70
+ return DEFAULT_MODEL
71
+
72
+
73
+ def embedding_dims(model_name: Optional[str] = None) -> int:
74
+ """Dimension of the active embedding model — derived, not hardcoded.
75
+
76
+ Prefers the actually-loaded model (which may have fallen back to the
77
+ default when a configured name failed to load), then fastembed's own
78
+ supported-models metadata, then the documented known-dims map.
79
+ """
80
+ name = model_name or _loaded_model_name or get_model_name()
81
+ dim = _dims_from_fastembed(name)
82
+ if dim is not None:
83
+ return dim
84
+ return _KNOWN_DIMS.get(name, EMBEDDING_DIMS)
85
+
86
+
87
+ def _dims_from_fastembed(name: str) -> Optional[int]:
88
+ try:
89
+ from fastembed import TextEmbedding
90
+ for entry in TextEmbedding.list_supported_models():
91
+ info = entry if isinstance(entry, dict) else vars(entry)
92
+ if info.get("model") == name and info.get("dim"):
93
+ return int(info["dim"])
94
+ except Exception: # noqa: BLE001 — degraded envs must never raise here
95
+ pass
96
+ return None
97
+
14
98
 
15
99
  def get_model():
16
- """Get or create the embedding model (lazy singleton)."""
17
- global _model
18
- if _model is None:
100
+ """Get or create the embedding model (lazy singleton, config-aware)."""
101
+ global _model, _loaded_model_name
102
+ name = get_model_name()
103
+ if _model is not None and _loaded_model_name == name:
104
+ return _model
105
+ try:
106
+ from fastembed import TextEmbedding
107
+ except ImportError:
108
+ return None
109
+ try:
110
+ _model = TextEmbedding(name)
111
+ _loaded_model_name = name
112
+ except Exception as exc: # noqa: BLE001 — unsupported model name, etc.
113
+ logger.warning(
114
+ "embed model %r failed to load (%s) — falling back to %s",
115
+ name, exc, DEFAULT_MODEL,
116
+ )
117
+ if name == DEFAULT_MODEL:
118
+ return None
19
119
  try:
20
- from fastembed import TextEmbedding
21
- _model = TextEmbedding(_model_name)
22
- except ImportError:
120
+ _model = TextEmbedding(DEFAULT_MODEL)
121
+ _loaded_model_name = DEFAULT_MODEL
122
+ except Exception: # noqa: BLE001
23
123
  return None
24
124
  return _model
25
125
 
26
126
 
127
+ def reset_model_cache() -> None:
128
+ """Drop the cached model so the next call re-resolves configuration."""
129
+ global _model, _loaded_model_name
130
+ _model = None
131
+ _loaded_model_name = None
132
+
133
+
27
134
  def embed(text: str) -> Optional[list[float]]:
28
135
  """Embed a single text. Returns None if fastembed unavailable."""
29
136
  model = get_model()
@@ -46,7 +153,7 @@ def embed_batch(texts: list[str]) -> Optional[list[list[float]]]:
46
153
  def is_available() -> bool:
47
154
  """Check if embedding model is available."""
48
155
  try:
49
- from fastembed import TextEmbedding
156
+ from fastembed import TextEmbedding # noqa: F401
50
157
  return True
51
158
  except ImportError:
52
159
  return False
@@ -1,19 +1,29 @@
1
1
  """Vector store — SQLite-vec backed semantic search.
2
2
 
3
3
  Stores document chunks with embeddings for fast similarity search.
4
- Graceful degradation: works without sqlite-vec (brute-force fallback).
4
+ Graceful degradation: works without sqlite-vec (keyword fallback) — but
5
+ HONESTLY (PR-3 v4.1): degraded results carry ``retrieval:
6
+ "keyword-degraded"`` and ``score: None`` instead of a fake similarity
7
+ score, and the first degraded search per process emits a visible
8
+ stderr warning explaining why semantic search is unavailable.
5
9
  """
6
10
 
7
11
  import json
12
+ import logging
13
+ import re
8
14
  import sqlite3
15
+ import sys
9
16
  import time
10
17
  from pathlib import Path
11
18
  from typing import Any, Optional
12
19
 
13
- from core.knowledge.embedder import embed, embed_batch, EMBEDDING_DIMS
20
+ from core.knowledge.embedder import embed, embed_batch, embedding_dims
14
21
 
22
+ logger = logging.getLogger(__name__)
15
23
 
16
24
  _VEC_UNAVAILABLE_REASON: str = "" # set by _load_vec on failure
25
+ _DEGRADED_WARNED: bool = False # one-time per-process degradation warning
26
+ _VEC_DIMS_RE = re.compile(r"float\[(\d+)\]")
17
27
 
18
28
 
19
29
  def _load_vec(db: sqlite3.Connection) -> bool:
@@ -61,6 +71,32 @@ def vec_unavailable_reason() -> str:
61
71
  return _VEC_UNAVAILABLE_REASON
62
72
 
63
73
 
74
+ def _parse_vec_dims(create_sql: str | None) -> Optional[int]:
75
+ """Extract the float[N] dimension from a vec0 CREATE statement."""
76
+ if not create_sql:
77
+ return None
78
+ match = _VEC_DIMS_RE.search(create_sql)
79
+ return int(match.group(1)) if match else None
80
+
81
+
82
+ def _warn_degraded_once(reason: str) -> None:
83
+ """Emit the one-time visible degradation warning (stderr + log)."""
84
+ global _DEGRADED_WARNED
85
+ if _DEGRADED_WARNED:
86
+ return
87
+ _DEGRADED_WARNED = True
88
+ msg = (
89
+ f"[arka:kb-degraded] semantic search unavailable ({reason}) — "
90
+ "results are keyword matches, NOT similarity-ranked. "
91
+ "Fix: pip install fastembed sqlite-vec (or: npx arkaos doctor)."
92
+ )
93
+ try:
94
+ sys.stderr.write(msg + "\n")
95
+ except OSError:
96
+ pass
97
+ logger.warning(msg)
98
+
99
+
64
100
  class VectorStore:
65
101
  """SQLite-vec backed vector store for knowledge retrieval.
66
102
 
@@ -108,15 +144,40 @@ class VectorStore:
108
144
  CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(file_hash);
109
145
  """)
110
146
  self._migrate_vss_to_vec()
147
+ self._vec_dims = embedding_dims() # derived from the active model
111
148
  if self._vec_available:
112
149
  try:
113
- self._db.execute(
114
- f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(embedding float[{EMBEDDING_DIMS}])"
115
- )
150
+ stored = self._stored_vec_dims()
151
+ if stored is not None and stored != self._vec_dims:
152
+ # An existing index was built with a different model
153
+ # dimension. NEVER corrupt it — keep the stored dim
154
+ # and let dimension guards route mismatched vectors
155
+ # to the degraded path until the user re-indexes.
156
+ logger.warning(
157
+ "vec_chunks dimension %d differs from configured "
158
+ "embed model dimension %d — keeping the stored "
159
+ "index. Re-index (/arka index) to switch models.",
160
+ stored, self._vec_dims,
161
+ )
162
+ self._vec_dims = stored
163
+ else:
164
+ self._db.execute(
165
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(embedding float[{self._vec_dims}])"
166
+ )
116
167
  except Exception:
117
168
  self._vec_available = False
118
169
  self._db.commit()
119
170
 
171
+ def _stored_vec_dims(self) -> Optional[int]:
172
+ """Dimension of an existing vec_chunks table, if any."""
173
+ try:
174
+ row = self._db.execute(
175
+ "SELECT sql FROM sqlite_master WHERE name = 'vec_chunks'"
176
+ ).fetchone()
177
+ except sqlite3.Error:
178
+ return None
179
+ return _parse_vec_dims(row["sql"] if row else None)
180
+
120
181
  def _migrate_vss_to_vec(self) -> None:
121
182
  """Drop legacy sqlite-vss tables if present.
122
183
 
@@ -176,7 +237,15 @@ class VectorStore:
176
237
  (text, heading, source, file_hash, meta_json, emb_blob),
177
238
  )
178
239
 
179
- if self._vec_available and emb_blob:
240
+ # Dimension guard: a vector produced by a differently-sized
241
+ # model must never be inserted into the vec0 table (it
242
+ # would poison distance queries). The raw embedding is
243
+ # still stored on the chunk row for a later re-index.
244
+ if (
245
+ self._vec_available
246
+ and emb_blob
247
+ and len(embeddings[i]) == self._vec_dims
248
+ ):
180
249
  self._db.execute(
181
250
  "INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)",
182
251
  (cursor.lastrowid, emb_blob),
@@ -199,13 +268,24 @@ class VectorStore:
199
268
  query_emb = embed(query)
200
269
 
201
270
  if query_emb and self._vec_available:
271
+ if len(query_emb) != self._vec_dims:
272
+ return self._keyword_search(
273
+ query, top_k,
274
+ reason="embed model dimension differs from the stored "
275
+ "index — re-index to switch models",
276
+ )
202
277
  try:
203
278
  return self._vec_search(query_emb, top_k)
204
279
  except Exception:
205
- return self._keyword_search(query, top_k)
280
+ return self._keyword_search(query, top_k, reason="vector query failed")
206
281
 
207
- # Fallback: keyword search
208
- return self._keyword_search(query, top_k)
282
+ # Fallback: keyword search — labeled, never presented as semantic.
283
+ return self._keyword_search(query, top_k, reason=self._degradation_reason(query_emb))
284
+
285
+ def _degradation_reason(self, query_emb: Optional[list[float]]) -> str:
286
+ if query_emb is None:
287
+ return "fastembed missing or embedding failed"
288
+ return vec_unavailable_reason() or "sqlite-vec unavailable"
209
289
 
210
290
  def _vec_search(self, query_emb: list[float], top_k: int) -> list[dict]:
211
291
  """Vector similarity search via sqlite-vec."""
@@ -224,19 +304,31 @@ class VectorStore:
224
304
  "heading": r["heading"],
225
305
  "source": r["source"],
226
306
  "score": 1.0 / (1.0 + r["distance"]), # Convert distance to similarity
307
+ "retrieval": "semantic",
227
308
  "metadata": json.loads(r["metadata"]),
228
309
  }
229
310
  for r in rows
230
311
  ]
231
312
 
232
- def _keyword_search(self, query: str, top_k: int) -> list[dict]:
233
- """Fallback keyword search when VSS unavailable."""
234
- words = query.lower().split()
313
+ def _keyword_search(self, query: str, top_k: int, reason: str = "") -> list[dict]:
314
+ """Fallback keyword search when semantic retrieval is unavailable.
315
+
316
+ HONESTY CONTRACT (PR-3 v4.1): these results are substring matches,
317
+ not similarity-ranked. ``score`` is None (there is no similarity)
318
+ and ``retrieval`` is ``"keyword-degraded"`` so every consumer can
319
+ label them. The previous behaviour — a hardcoded fake ``0.5``
320
+ score — silently impersonated semantic search.
321
+ """
322
+ words = query.lower().split()[:5] # Max 5 keywords
235
323
  if not words:
236
324
  return []
237
325
 
326
+ _warn_degraded_once(reason or "semantic retrieval unavailable")
327
+
328
+ # Placeholders and params MUST come from the same capped list —
329
+ # previously a 6+ word query produced more `?` than bindings.
238
330
  conditions = " OR ".join(["lower(text) LIKE ?" for _ in words])
239
- params = [f"%{w}%" for w in words[:5]] # Max 5 keywords
331
+ params = [f"%{w}%" for w in words]
240
332
 
241
333
  rows = self._db.execute(
242
334
  f"SELECT text, heading, source, metadata FROM chunks WHERE {conditions} LIMIT ?",
@@ -248,7 +340,8 @@ class VectorStore:
248
340
  "text": r["text"],
249
341
  "heading": r["heading"],
250
342
  "source": r["source"],
251
- "score": 0.5, # No real score for keyword search
343
+ "score": None, # keyword match NO similarity score exists
344
+ "retrieval": "keyword-degraded",
252
345
  "metadata": json.loads(r["metadata"]),
253
346
  }
254
347
  for r in rows
@@ -274,12 +367,16 @@ class VectorStore:
274
367
 
275
368
  def get_stats(self) -> dict:
276
369
  """Get store statistics."""
370
+ from core.knowledge.embedder import is_available as _embedder_available
371
+
277
372
  total = self._db.execute("SELECT COUNT(*) as cnt FROM chunks").fetchone()["cnt"]
278
373
  sources = self._db.execute("SELECT COUNT(DISTINCT source) as cnt FROM chunks").fetchone()["cnt"]
374
+ semantic = self._vec_available and _embedder_available()
279
375
  return {
280
376
  "total_chunks": total,
281
377
  "total_files": sources,
282
378
  "vec_available": self._vec_available,
379
+ "retrieval_mode": "semantic" if semantic else "keyword-degraded",
283
380
  "db_path": self._db_path,
284
381
  }
285
382
 
@@ -36,6 +36,13 @@ _STOPWORDS = frozenset({
36
36
  })
37
37
 
38
38
 
39
+ # Jaccard between full documents rarely exceeds ~0.3 even for clearly
40
+ # related notes, while cosine similarity of embeddings sits comfortably
41
+ # above 0.5. A single threshold calibrated for cosine silently disabled
42
+ # the relator on every machine without fastembed (v4.1.1 fix).
43
+ _KEYWORD_MIN_SIMILARITY = 0.15
44
+
45
+
39
46
  def find_related(
40
47
  content: str,
41
48
  vault: Path,
@@ -48,8 +55,9 @@ def find_related(
48
55
  candidates = _collect_notes(vault, exclude)
49
56
  if not candidates:
50
57
  return []
51
- scored = _score_candidates(content, candidates)
52
- filtered = [r for r in scored if r.score >= min_similarity]
58
+ scored, method = _score_candidates(content, candidates)
59
+ threshold = min_similarity if method == "semantic" else _KEYWORD_MIN_SIMILARITY
60
+ filtered = [r for r in scored if r.score >= threshold]
53
61
  filtered.sort(key=lambda r: r.score, reverse=True)
54
62
  return filtered[:top_n]
55
63
 
@@ -70,22 +78,24 @@ def _collect_notes(vault: Path, exclude: Optional[Path]) -> list[tuple[Path, str
70
78
 
71
79
  def _score_candidates(
72
80
  content: str, candidates: list[tuple[Path, str]]
73
- ) -> list[RelatedNote]:
81
+ ) -> tuple[list[RelatedNote], str]:
82
+ """Score candidates, returning (results, method) so the caller can
83
+ apply a threshold appropriate to the scoring space."""
74
84
  try:
75
85
  return _semantic_score(content, candidates)
76
86
  except Exception:
77
- return _keyword_score(content, candidates)
87
+ return _keyword_score(content, candidates), "keyword"
78
88
 
79
89
 
80
90
  def _semantic_score(
81
91
  content: str, candidates: list[tuple[Path, str]]
82
- ) -> list[RelatedNote]:
92
+ ) -> tuple[list[RelatedNote], str]:
83
93
  from core.knowledge.embedder import embed, is_available
84
94
  if not is_available():
85
- return _keyword_score(content, candidates)
95
+ return _keyword_score(content, candidates), "keyword"
86
96
  query_emb = embed(content)
87
97
  if query_emb is None:
88
- return _keyword_score(content, candidates)
98
+ return _keyword_score(content, candidates), "keyword"
89
99
  results = []
90
100
  for path, text in candidates:
91
101
  note_emb = embed(text[:2000])
@@ -96,7 +106,7 @@ def _semantic_score(
96
106
  path=path, title=_title_from_path(path),
97
107
  score=score, excerpt=text[:160],
98
108
  ))
99
- return results
109
+ return results, "semantic"
100
110
 
101
111
 
102
112
  def _keyword_score(
@@ -145,6 +145,35 @@ class RuntimeAdapter(ABC):
145
145
  }
146
146
  return feature_map.get(feature, False)
147
147
 
148
+ def capabilities(self) -> dict[str, bool]:
149
+ """Honest, static support matrix for this adapter (PR-6 v4.1.0).
150
+
151
+ Single source of truth for the multi-runtime story — consumed by
152
+ ``python -m core.runtime.capabilities_cli`` and wiki/08.
153
+
154
+ Keys (all bool):
155
+ - ``agent_dispatch``: the runtime can dispatch ArkaOS agents /
156
+ subagents natively (Claude Code Agent tool). False when
157
+ ``dispatch_agent`` only returns a descriptor for a runtime that
158
+ cannot actually spawn ArkaOS agents.
159
+ - ``headless``: the adapter implements a real one-shot
160
+ ``headless_complete`` against the runtime's CLI (static — use
161
+ ``headless_supported()`` for binary-on-PATH detection).
162
+ - ``file_ops``: the runtime performs file operations natively
163
+ when driven interactively (single-agent use counts).
164
+ - ``hooks``: the runtime exposes lifecycle hooks that ArkaOS
165
+ enforcement (PreToolUse/Stop/...) can attach to.
166
+
167
+ Default: conservative all-False except hooks, which mirrors the
168
+ runtime config — unknown adapters must not overclaim.
169
+ """
170
+ return {
171
+ "agent_dispatch": False,
172
+ "headless": False,
173
+ "file_ops": False,
174
+ "hooks": self.get_config().supports_hooks,
175
+ }
176
+
148
177
  def headless_supported(self) -> bool:
149
178
  """Whether this adapter can perform a headless CLI completion.
150
179
 
@@ -0,0 +1,74 @@
1
+ """Runtime capabilities matrix CLI (PR-6 v4.1.0 — multi-runtime honesty).
2
+
3
+ Single source of truth for what each runtime adapter actually supports.
4
+ Prints the static ``capabilities()`` matrix of all registered adapters,
5
+ plus live binary detection for the headless CLIs.
6
+
7
+ Usage:
8
+ python -m core.runtime.capabilities_cli # table
9
+ python -m core.runtime.capabilities_cli --json # machine-readable
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+
18
+ from core.runtime.registry import RUNTIME_ADAPTERS
19
+
20
+ _CAP_KEYS = ("agent_dispatch", "headless", "file_ops", "hooks")
21
+
22
+
23
+ def build_matrix() -> dict[str, dict]:
24
+ """Collect capabilities + live headless availability per runtime."""
25
+ matrix: dict[str, dict] = {}
26
+ for runtime_id, adapter_cls in RUNTIME_ADAPTERS.items():
27
+ adapter = adapter_cls()
28
+ caps = adapter.capabilities()
29
+ row = {key: bool(caps.get(key, False)) for key in _CAP_KEYS}
30
+ try:
31
+ row["headless_available"] = bool(adapter.headless_supported())
32
+ except Exception: # noqa: BLE001 — detection must never crash
33
+ row["headless_available"] = False
34
+ row["name"] = adapter.get_config().name
35
+ matrix[runtime_id] = row
36
+ return matrix
37
+
38
+
39
+ def _render_table(matrix: dict[str, dict]) -> str:
40
+ header = (
41
+ f"{'runtime':<14}{'agent_dispatch':<16}{'headless':<10}"
42
+ f"{'file_ops':<10}{'hooks':<7}{'binary on PATH'}"
43
+ )
44
+ lines = [header, "-" * len(header)]
45
+ for runtime_id, row in matrix.items():
46
+ def mark(key: str) -> str:
47
+ return "yes" if row.get(key) else "no"
48
+ lines.append(
49
+ f"{runtime_id:<14}{mark('agent_dispatch'):<16}"
50
+ f"{mark('headless'):<10}{mark('file_ops'):<10}"
51
+ f"{mark('hooks'):<7}{mark('headless_available')}"
52
+ )
53
+ return "\n".join(lines)
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ parser = argparse.ArgumentParser(
58
+ prog="python -m core.runtime.capabilities_cli",
59
+ description="Print the multi-runtime support matrix.",
60
+ )
61
+ parser.add_argument(
62
+ "--json", action="store_true", help="Emit JSON instead of a table"
63
+ )
64
+ args = parser.parse_args(argv)
65
+ matrix = build_matrix()
66
+ if args.json:
67
+ print(json.dumps(matrix, indent=2))
68
+ else:
69
+ print(_render_table(matrix))
70
+ return 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ sys.exit(main())