arkaos 4.20.0 → 4.22.0
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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +2 -1
- package/config/claude-agents/marta-cqo.md +9 -0
- package/config/standards/{claude-md-overlays → stack-rules}/laravel.md +11 -0
- package/config/standards/{claude-md-overlays → stack-rules}/node.md +10 -1
- package/config/standards/{claude-md-overlays → stack-rules}/nuxt.md +12 -1
- package/config/standards/stack-rules/php.md +13 -0
- package/config/standards/{claude-md-overlays → stack-rules}/python.md +5 -0
- package/config/standards/stack-rules/react.md +14 -0
- package/config/standards/stack-rules/vue.md +13 -0
- package/core/cognition/evolve.py +299 -0
- package/core/cognition/evolve_cli.py +55 -0
- package/core/governance/stop_lint.py +266 -0
- package/core/governance/stop_lint_telemetry.py +132 -0
- package/core/governance/tool_loop_check.py +111 -0
- package/core/hooks/stop.py +73 -22
- package/core/runtime/pricing.py +53 -19
- package/core/synapse/layers.py +49 -476
- package/core/synapse/layers_base.py +99 -0
- package/core/synapse/layers_kb.py +395 -0
- package/core/sync/content_syncer.py +79 -10
- package/dashboard/app/components/AgentDependencyGraph.vue +5 -5
- package/dashboard/app/components/AgentEditDrawer.vue +120 -52
- package/dashboard/app/components/AgentSuggestionsCard.vue +4 -2
- package/dashboard/app/components/ConfirmDialog.vue +2 -2
- package/dashboard/app/components/DashboardState.vue +5 -3
- package/dashboard/app/components/GlobalSearch.vue +11 -7
- package/dashboard/app/components/KeyboardShortcutsHelp.vue +8 -6
- package/dashboard/app/components/KnowledgeSourcesList.vue +13 -9
- package/dashboard/app/components/MarkdownEditor.vue +1 -1
- package/dashboard/app/components/NotificationsBell.vue +8 -4
- package/dashboard/app/components/OnboardingTour.vue +24 -11
- package/dashboard/app/components/PersonaCloneDialog.vue +15 -8
- package/dashboard/app/components/PersonaWizard.vue +105 -72
- package/dashboard/app/components/SidebarFavoritesWidget.vue +5 -5
- package/dashboard/app/components/SidebarStatsWidget.vue +3 -1
- package/dashboard/app/components/Terminal.vue +9 -8
- package/dashboard/app/components/TextDiff.vue +27 -15
- package/dashboard/app/composables/useActivityFeed.ts +3 -3
- package/dashboard/app/composables/useApi.ts +4 -2
- package/dashboard/app/composables/useConfirmDialog.ts +1 -1
- package/dashboard/app/composables/useFavorites.ts +8 -8
- package/dashboard/app/composables/useTerminalThemes.ts +13 -13
- package/dashboard/app/composables/useThemeColor.ts +7 -4
- package/dashboard/app/pages/agents/[id].vue +319 -159
- package/dashboard/app/pages/agents/compare.vue +152 -52
- package/dashboard/app/pages/agents/index.vue +68 -55
- package/dashboard/app/pages/agents/new.vue +125 -58
- package/dashboard/app/pages/audit.vue +13 -6
- package/dashboard/app/pages/budget.vue +20 -18
- package/dashboard/app/pages/commands.vue +26 -22
- package/dashboard/app/pages/departments/[dept].vue +67 -31
- package/dashboard/app/pages/departments/compare.vue +85 -27
- package/dashboard/app/pages/departments/index.vue +31 -7
- package/dashboard/app/pages/health.vue +19 -12
- package/dashboard/app/pages/knowledge/index.vue +190 -78
- package/dashboard/app/pages/models.vue +74 -28
- package/dashboard/app/pages/personas/[id].vue +322 -219
- package/dashboard/app/pages/personas/archetypes.vue +20 -6
- package/dashboard/app/pages/personas/compare-with-agent.vue +109 -39
- package/dashboard/app/pages/personas/compare.vue +196 -124
- package/dashboard/app/pages/personas/index.vue +67 -60
- package/dashboard/app/pages/personas/new.vue +1 -1
- package/dashboard/app/pages/settings.vue +1 -0
- package/dashboard/app/pages/tasks.vue +80 -38
- package/dashboard/app/pages/trash.vue +11 -9
- package/dashboard/app/pages/workflows.vue +29 -19
- package/dashboard/nuxt.config.ts +2 -1
- package/dashboard/package.json +5 -0
- package/departments/kb/skills/doc-extraction/SKILL.md +99 -0
- package/departments/kb/skills/doc-redaction/SKILL.md +103 -0
- package/departments/kb/skills/knowledge-ops/SKILL.md +88 -0
- package/departments/ops/skills/github-ops/SKILL.md +89 -0
- package/departments/ops/skills/terminal-ops/SKILL.md +83 -0
- package/installer/index.js +1 -1
- package/knowledge/commands-registry.json +16 -3
- package/knowledge/skills-manifest.json +66 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/core/synapse/layers.py
CHANGED
|
@@ -14,106 +14,35 @@ Layer Architecture:
|
|
|
14
14
|
L5: Command Hints — Matching commands from registry (TTL: 30s)
|
|
15
15
|
L6: Quality Gate — QG status and last verdicts (TTL: 60s)
|
|
16
16
|
L7: Time — Time-of-day signal (no cache)
|
|
17
|
+
|
|
18
|
+
Module layout (v4.21.0 split): the layer contract lives in
|
|
19
|
+
layers_base.py; the L2.5 KB layer and its helpers live in
|
|
20
|
+
layers_kb.py. Both are re-exported here so existing
|
|
21
|
+
`core.synapse.layers` imports keep working.
|
|
17
22
|
"""
|
|
18
23
|
|
|
19
|
-
import json
|
|
20
|
-
import os
|
|
21
24
|
import re
|
|
22
25
|
import time
|
|
23
|
-
from
|
|
24
|
-
|
|
25
|
-
from
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"""Input context for layer computation."""
|
|
44
|
-
|
|
45
|
-
user_input: str = ""
|
|
46
|
-
cwd: str = ""
|
|
47
|
-
git_branch: str = ""
|
|
48
|
-
project_name: str = ""
|
|
49
|
-
project_stack: str = ""
|
|
50
|
-
active_agent: str = ""
|
|
51
|
-
runtime_id: str = "claude-code"
|
|
52
|
-
extra: dict[str, Any] = None
|
|
53
|
-
|
|
54
|
-
def __post_init__(self) -> None:
|
|
55
|
-
if self.extra is None:
|
|
56
|
-
self.extra = {}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class Layer(ABC):
|
|
60
|
-
"""Abstract base class for a Synapse context layer."""
|
|
61
|
-
|
|
62
|
-
@property
|
|
63
|
-
@abstractmethod
|
|
64
|
-
def id(self) -> str:
|
|
65
|
-
"""Unique layer identifier (e.g., 'L0', 'L1')."""
|
|
66
|
-
|
|
67
|
-
@property
|
|
68
|
-
@abstractmethod
|
|
69
|
-
def name(self) -> str:
|
|
70
|
-
"""Human-readable name."""
|
|
71
|
-
|
|
72
|
-
@property
|
|
73
|
-
def cache_ttl(self) -> int:
|
|
74
|
-
"""Cache TTL in seconds. 0 = no caching."""
|
|
75
|
-
return 0
|
|
76
|
-
|
|
77
|
-
@property
|
|
78
|
-
def input_sensitive(self) -> bool:
|
|
79
|
-
"""True when compute() depends on ctx.user_input.
|
|
80
|
-
|
|
81
|
-
Input-sensitive layers get the prompt hashed into their cache
|
|
82
|
-
key — without it, a cached result from one prompt is served for
|
|
83
|
-
a DIFFERENT prompt within the TTL window (found 2026-07-09: L5
|
|
84
|
-
served 'hello' hints for an explicit '/dev feature' command,
|
|
85
|
-
defeating its own slash-suppression rule).
|
|
86
|
-
"""
|
|
87
|
-
return False
|
|
88
|
-
|
|
89
|
-
@property
|
|
90
|
-
def session_sensitive(self) -> bool:
|
|
91
|
-
"""True when compute() has per-session side effects or output.
|
|
92
|
-
|
|
93
|
-
Session-sensitive layers get ctx.extra['session_id'] added to
|
|
94
|
-
their cache key — without it, a cache hit from one session
|
|
95
|
-
suppresses compute() for a concurrent session in the same cwd,
|
|
96
|
-
skipping that session's side effects (found 2026-07-09: L3.5's
|
|
97
|
-
KBSessionCache.store() never ran for the second session, so its
|
|
98
|
-
KB-injected state and overlap markers belonged to the first).
|
|
99
|
-
"""
|
|
100
|
-
return False
|
|
101
|
-
|
|
102
|
-
@property
|
|
103
|
-
def priority(self) -> int:
|
|
104
|
-
"""Layer priority (lower = computed first)."""
|
|
105
|
-
return 50
|
|
106
|
-
|
|
107
|
-
@abstractmethod
|
|
108
|
-
def compute(self, ctx: PromptContext) -> LayerResult:
|
|
109
|
-
"""Compute this layer's context.
|
|
110
|
-
|
|
111
|
-
Args:
|
|
112
|
-
ctx: The prompt context with user input and environment.
|
|
113
|
-
|
|
114
|
-
Returns:
|
|
115
|
-
LayerResult with the computed context.
|
|
116
|
-
"""
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from core.synapse.layers_base import Layer, LayerResult, PromptContext
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Layer",
|
|
32
|
+
"LayerResult",
|
|
33
|
+
"PromptContext",
|
|
34
|
+
"ConstitutionLayer",
|
|
35
|
+
"DepartmentLayer",
|
|
36
|
+
"AgentLayer",
|
|
37
|
+
"ProjectLayer",
|
|
38
|
+
"BranchLayer",
|
|
39
|
+
"CommandHintsLayer",
|
|
40
|
+
"QualityGateLayer",
|
|
41
|
+
"KnowledgeRetrievalLayer",
|
|
42
|
+
"ForgeContextLayer",
|
|
43
|
+
"SessionContextLayer",
|
|
44
|
+
"KBContextLayer",
|
|
45
|
+
]
|
|
117
46
|
|
|
118
47
|
|
|
119
48
|
# --- L0: Constitution ---
|
|
@@ -786,384 +715,28 @@ class SessionContextLayer(Layer):
|
|
|
786
715
|
cached=False,
|
|
787
716
|
)
|
|
788
717
|
|
|
789
|
-
|
|
790
718
|
# --- L2.5: KB Context (Obsidian) -------------------------------------------
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
return True
|
|
816
|
-
try:
|
|
817
|
-
data = json.loads(_KB_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
818
|
-
except (json.JSONDecodeError, OSError):
|
|
819
|
-
return True
|
|
820
|
-
synapse_cfg = data.get("synapse") or {}
|
|
821
|
-
return bool(synapse_cfg.get("l25KbContext", True))
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
def _tokenize_for_jaccard(text: str) -> set[str]:
|
|
825
|
-
if not text:
|
|
826
|
-
return set()
|
|
827
|
-
words = re.findall(r"[a-zA-Z0-9]{3,}", text.lower())
|
|
828
|
-
return {w for w in words if w not in _KB_STOPWORDS}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
832
|
-
if not a or not b:
|
|
833
|
-
return 0.0
|
|
834
|
-
inter = len(a & b)
|
|
835
|
-
union = len(a | b)
|
|
836
|
-
return inter / union if union else 0.0
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
def _extract_note_body(raw: str) -> str:
|
|
840
|
-
return _FRONTMATTER_RE.sub("", raw, count=1).lstrip()
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
def _extract_title(raw: str, fallback: str) -> str:
|
|
844
|
-
body = _extract_note_body(raw)
|
|
845
|
-
for line in body.splitlines():
|
|
846
|
-
stripped = line.strip()
|
|
847
|
-
if stripped.startswith("#"):
|
|
848
|
-
return stripped.lstrip("#").strip() or fallback
|
|
849
|
-
if stripped:
|
|
850
|
-
return fallback
|
|
851
|
-
return fallback
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
def _extract_excerpt(raw: str, max_lines: int = 2) -> str:
|
|
855
|
-
body = _extract_note_body(raw)
|
|
856
|
-
lines: list[str] = []
|
|
857
|
-
for line in body.splitlines():
|
|
858
|
-
stripped = line.strip()
|
|
859
|
-
if not stripped or stripped.startswith("#"):
|
|
860
|
-
continue
|
|
861
|
-
lines.append(stripped)
|
|
862
|
-
if len(lines) >= max_lines:
|
|
863
|
-
break
|
|
864
|
-
return " ".join(lines)[:240]
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
def _extract_wikilinks(raw: str, limit: int = 3) -> list[str]:
|
|
868
|
-
body = _extract_note_body(raw)
|
|
869
|
-
seen: list[str] = []
|
|
870
|
-
for match in _WIKILINK_RE.finditer(body):
|
|
871
|
-
target = match.group(1).strip()
|
|
872
|
-
if target and target not in seen:
|
|
873
|
-
seen.append(target)
|
|
874
|
-
if len(seen) >= limit:
|
|
875
|
-
break
|
|
876
|
-
return seen
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
def _format_kb_block(notes: list[dict], degraded: bool = False) -> str:
|
|
880
|
-
lines: list[str] = [
|
|
881
|
-
f"[arka:kb-context] O teu cérebro (Obsidian) tem {len(notes)} "
|
|
882
|
-
f"nota{'s' if len(notes) != 1 else ''} relevante{'s' if len(notes) != 1 else ''} "
|
|
883
|
-
f"para este pedido:",
|
|
884
|
-
"",
|
|
885
|
-
]
|
|
886
|
-
if degraded:
|
|
887
|
-
lines.insert(1, "")
|
|
888
|
-
lines.insert(
|
|
889
|
-
1,
|
|
890
|
-
"Atenção: correspondência por palavras-chave (pesquisa semântica "
|
|
891
|
-
"indisponível) — NÃO é similaridade semântica.",
|
|
892
|
-
)
|
|
893
|
-
for note in notes:
|
|
894
|
-
title = note.get("title", "")
|
|
895
|
-
path = note.get("path", "")
|
|
896
|
-
excerpt = note.get("excerpt", "")
|
|
897
|
-
relates = note.get("relates", []) or []
|
|
898
|
-
suffix = " (inferred — not authoritative)" if note.get("inferred") else ""
|
|
899
|
-
lines.append(f"- [[{title}]]{suffix} (path: `{path}`)")
|
|
900
|
-
if excerpt:
|
|
901
|
-
lines.append(f" Excerto: {excerpt}")
|
|
902
|
-
if relates:
|
|
903
|
-
rel = ", ".join(f"[[{r}]]" for r in relates)
|
|
904
|
-
lines.append(f" Relacionada: {rel}")
|
|
905
|
-
lines.append("")
|
|
906
|
-
lines.append(
|
|
907
|
-
"Consulta-as antes de ir a Context7/Web. Se preencherem o pedido, "
|
|
908
|
-
"usa-as e cita. Se tiverem lacuna, investiga externamente e "
|
|
909
|
-
"documenta de volta."
|
|
910
|
-
)
|
|
911
|
-
return "\n".join(lines).strip()
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
def _vector_search(store: Any, prompt: str, top_k: int) -> list[dict]:
|
|
915
|
-
if store is None:
|
|
916
|
-
return []
|
|
917
|
-
try:
|
|
918
|
-
return list(store.search(prompt, top_k=top_k)) or []
|
|
919
|
-
except Exception:
|
|
920
|
-
return []
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
def _jaccard_fallback(
|
|
924
|
-
prompt: str, notes: list[dict], top_k: int, threshold: float
|
|
925
|
-
) -> list[dict]:
|
|
926
|
-
prompt_tokens = _tokenize_for_jaccard(prompt)
|
|
927
|
-
scored: list[tuple[float, dict]] = []
|
|
928
|
-
for note in notes:
|
|
929
|
-
title_tokens = _tokenize_for_jaccard(note.get("title", ""))
|
|
930
|
-
score = _jaccard(prompt_tokens, title_tokens)
|
|
931
|
-
if score >= threshold:
|
|
932
|
-
scored.append((score, note))
|
|
933
|
-
scored.sort(key=lambda x: x[0], reverse=True)
|
|
934
|
-
return [n for _, n in scored[:top_k]]
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
def _load_fallback_notes(vault_path: Optional[Path]) -> list[dict]:
|
|
938
|
-
if vault_path is None or not vault_path.exists() or not vault_path.is_dir():
|
|
939
|
-
return []
|
|
940
|
-
notes: list[dict] = []
|
|
941
|
-
for md in sorted(vault_path.rglob("*.md")):
|
|
942
|
-
if len(notes) >= _MAX_FALLBACK_NOTES:
|
|
943
|
-
break
|
|
944
|
-
try:
|
|
945
|
-
raw = md.read_text(encoding="utf-8")
|
|
946
|
-
except (OSError, UnicodeDecodeError):
|
|
947
|
-
continue
|
|
948
|
-
notes.append(
|
|
949
|
-
{
|
|
950
|
-
"title": _extract_title(raw, md.stem),
|
|
951
|
-
"path": str(md),
|
|
952
|
-
"raw": raw,
|
|
953
|
-
}
|
|
954
|
-
)
|
|
955
|
-
return notes
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
_GROUNDING_INFERRED_RE = re.compile(r"^grounding:\s*inferred\s*$", re.MULTILINE)
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
def _frontmatter_marks_inferred(raw: str) -> bool:
|
|
962
|
-
"""Cheap check: does the YAML frontmatter carry `grounding: inferred`?
|
|
963
|
-
|
|
964
|
-
Parses ONLY the frontmatter block (the note content is already in
|
|
965
|
-
hand) — no YAML library, no full-document scan. Dreaming-written
|
|
966
|
-
notes carry this marker (see core/cognition/dreaming.py, PR-3 v4.1).
|
|
967
|
-
"""
|
|
968
|
-
match = _FRONTMATTER_RE.match(raw or "")
|
|
969
|
-
if not match:
|
|
970
|
-
return False
|
|
971
|
-
return bool(_GROUNDING_INFERRED_RE.search(match.group(0)))
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
def _hit_is_inferred(hit: dict) -> bool:
|
|
975
|
-
"""Inferred check for a vector-store hit.
|
|
976
|
-
|
|
977
|
-
Chunk text has frontmatter stripped by the chunker, so check the hit
|
|
978
|
-
metadata first, then read just the head of the source file (cheap:
|
|
979
|
-
frontmatter lives in the first bytes).
|
|
980
|
-
"""
|
|
981
|
-
metadata = hit.get("metadata") or {}
|
|
982
|
-
if isinstance(metadata, dict) and metadata.get("grounding") == "inferred":
|
|
983
|
-
return True
|
|
984
|
-
source = hit.get("source", "") or ""
|
|
985
|
-
if not source:
|
|
986
|
-
return False
|
|
987
|
-
try:
|
|
988
|
-
with open(source, "r", encoding="utf-8", errors="ignore") as fh:
|
|
989
|
-
head = fh.read(2048)
|
|
990
|
-
except OSError:
|
|
991
|
-
return False
|
|
992
|
-
return _frontmatter_marks_inferred(head)
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
def _build_note_entry(
|
|
996
|
-
raw: str, title: str, path: str, score: float, inferred: bool = False
|
|
997
|
-
) -> dict:
|
|
998
|
-
return {
|
|
999
|
-
"title": title,
|
|
1000
|
-
"path": path,
|
|
1001
|
-
"excerpt": _extract_excerpt(raw),
|
|
1002
|
-
"relates": _extract_wikilinks(raw),
|
|
1003
|
-
"score": float(score),
|
|
1004
|
-
"inferred": inferred,
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
def _note_from_vector_hit(hit: dict) -> dict:
|
|
1009
|
-
source = hit.get("source", "") or ""
|
|
1010
|
-
raw = hit.get("text", "") or ""
|
|
1011
|
-
title = hit.get("heading") or Path(source).stem or "note"
|
|
1012
|
-
score_val = hit.get("score", 0.0) or 0.0
|
|
1013
|
-
return _build_note_entry(
|
|
1014
|
-
raw, str(title), str(source), float(score_val),
|
|
1015
|
-
inferred=_hit_is_inferred(hit),
|
|
1016
|
-
)
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
def _apply_grounding_policy(notes: list[dict], max_notes: int) -> list[dict]:
|
|
1020
|
-
"""Quarantine inferred notes (Dreaming output) from grounded context.
|
|
1021
|
-
|
|
1022
|
-
Policy (PR-3 v4.1): inferred notes are EXCLUDED by default; they are
|
|
1023
|
-
only included — explicitly suffixed `(inferred — not authoritative)`
|
|
1024
|
-
by the formatter — when fewer than 2 grounded notes matched.
|
|
1025
|
-
"""
|
|
1026
|
-
grounded = [n for n in notes if not n.get("inferred")]
|
|
1027
|
-
if len(grounded) >= 2:
|
|
1028
|
-
return grounded[:max_notes]
|
|
1029
|
-
inferred = [n for n in notes if n.get("inferred")]
|
|
1030
|
-
return (grounded + inferred)[:max_notes]
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
class KBContextLayer(Layer):
|
|
1034
|
-
"""L2.5: Obsidian KB context injection before the model thinks.
|
|
1035
|
-
|
|
1036
|
-
Design (see plan ``2026-04-20-intelligence-v2.md``):
|
|
1037
|
-
1. Semantic search the user prompt against the vector store.
|
|
1038
|
-
2. If store empty or embedder unavailable, fall back to Jaccard
|
|
1039
|
-
keyword similarity against cached note titles.
|
|
1040
|
-
3. Keep notes with similarity ≥ ``min_similarity`` (default 0.5),
|
|
1041
|
-
up to ``max_notes``.
|
|
1042
|
-
4. Format as ``[arka:kb-context]`` block with title, path, 2-line
|
|
1043
|
-
excerpt, and top 3 wikilinks per note.
|
|
1044
|
-
5. Call ``record_obsidian_query`` so research_gate (Task #6) can
|
|
1045
|
-
verify KB-first was respected this turn.
|
|
1046
|
-
|
|
1047
|
-
Feature flag: ``synapse.l25KbContext`` in ``~/.arkaos/config.json``
|
|
1048
|
-
(default ``true``). ``ARKA_BYPASS_L25=1`` env disables for debugging.
|
|
1049
|
-
"""
|
|
1050
|
-
|
|
1051
|
-
def __init__(
|
|
1052
|
-
self,
|
|
1053
|
-
vector_store: Any = None,
|
|
1054
|
-
vault_path: Optional[str] = None,
|
|
1055
|
-
max_notes: int = 5,
|
|
1056
|
-
min_similarity: float = 0.5,
|
|
1057
|
-
) -> None:
|
|
1058
|
-
self._store = vector_store
|
|
1059
|
-
self._vault_path = Path(vault_path) if vault_path else None
|
|
1060
|
-
self._max_notes = max_notes
|
|
1061
|
-
self._min_similarity = min_similarity
|
|
1062
|
-
|
|
1063
|
-
@property
|
|
1064
|
-
def id(self) -> str:
|
|
1065
|
-
return "L2.5"
|
|
1066
|
-
|
|
1067
|
-
@property
|
|
1068
|
-
def name(self) -> str:
|
|
1069
|
-
return "KBContext"
|
|
1070
|
-
|
|
1071
|
-
@property
|
|
1072
|
-
def input_sensitive(self) -> bool:
|
|
1073
|
-
return True
|
|
1074
|
-
|
|
1075
|
-
@property
|
|
1076
|
-
def cache_ttl(self) -> int:
|
|
1077
|
-
return 0
|
|
1078
|
-
|
|
1079
|
-
@property
|
|
1080
|
-
def priority(self) -> int:
|
|
1081
|
-
return 25
|
|
1082
|
-
|
|
1083
|
-
def _empty(self, start: float) -> LayerResult:
|
|
1084
|
-
ms = int((time.time() - start) * 1000)
|
|
1085
|
-
return LayerResult(
|
|
1086
|
-
layer_id=self.id, tag="", content="", tokens_est=0, compute_ms=ms, cached=False
|
|
1087
|
-
)
|
|
1088
|
-
|
|
1089
|
-
def _session_id(self, ctx: PromptContext) -> str:
|
|
1090
|
-
return ctx.extra.get("session_id", "") if ctx.extra else ""
|
|
1091
|
-
|
|
1092
|
-
def _record(self, ctx: PromptContext, hit_count: int) -> None:
|
|
1093
|
-
session_id = self._session_id(ctx)
|
|
1094
|
-
if not session_id:
|
|
1095
|
-
return
|
|
1096
|
-
try:
|
|
1097
|
-
from core.synapse.kb_cache import record_obsidian_query
|
|
1098
|
-
|
|
1099
|
-
record_obsidian_query(session_id, ctx.user_input, hit_count)
|
|
1100
|
-
except Exception:
|
|
1101
|
-
pass
|
|
1102
|
-
|
|
1103
|
-
def _retrieve(self, prompt: str) -> tuple[list[dict], bool]:
|
|
1104
|
-
"""Return (notes, degraded). Degraded = keyword-only retrieval.
|
|
1105
|
-
|
|
1106
|
-
Degraded hits carry no similarity score, so the min_similarity
|
|
1107
|
-
threshold does not apply to them — they are included but labeled
|
|
1108
|
-
(never presented as semantic matches).
|
|
1109
|
-
"""
|
|
1110
|
-
hits = _vector_search(self._store, prompt, top_k=self._max_notes * 2)
|
|
1111
|
-
degraded = any(h.get("retrieval") == "keyword-degraded" for h in hits)
|
|
1112
|
-
notes: list[dict] = []
|
|
1113
|
-
for h in hits:
|
|
1114
|
-
if not degraded:
|
|
1115
|
-
score = float(h.get("score", 0.0) or 0.0)
|
|
1116
|
-
if score < self._min_similarity:
|
|
1117
|
-
continue
|
|
1118
|
-
notes.append(_note_from_vector_hit(h))
|
|
1119
|
-
notes = _apply_grounding_policy(notes, self._max_notes)
|
|
1120
|
-
if notes:
|
|
1121
|
-
return notes, degraded
|
|
1122
|
-
candidates = _load_fallback_notes(self._vault_path)
|
|
1123
|
-
if not candidates:
|
|
1124
|
-
return [], False
|
|
1125
|
-
picked = _jaccard_fallback(
|
|
1126
|
-
prompt, candidates, self._max_notes * 2, self._min_similarity
|
|
1127
|
-
)
|
|
1128
|
-
fallback_notes = [
|
|
1129
|
-
_build_note_entry(
|
|
1130
|
-
n["raw"], n["title"], n["path"], 0.0,
|
|
1131
|
-
inferred=_frontmatter_marks_inferred(n["raw"]),
|
|
1132
|
-
)
|
|
1133
|
-
for n in picked
|
|
1134
|
-
]
|
|
1135
|
-
return _apply_grounding_policy(fallback_notes, self._max_notes), False
|
|
1136
|
-
|
|
1137
|
-
def build(self, prompt: str) -> Optional[str]:
|
|
1138
|
-
"""Public entrypoint — returns the formatted block or None."""
|
|
1139
|
-
if not prompt or not _l25_feature_flag_on():
|
|
1140
|
-
return None
|
|
1141
|
-
notes, degraded = self._retrieve(prompt[:2000])
|
|
1142
|
-
if not notes:
|
|
1143
|
-
return None
|
|
1144
|
-
return _format_kb_block(notes[: self._max_notes], degraded=degraded)
|
|
1145
|
-
|
|
1146
|
-
def compute(self, ctx: PromptContext) -> LayerResult:
|
|
1147
|
-
start = time.time()
|
|
1148
|
-
if not ctx.user_input or not _l25_feature_flag_on():
|
|
1149
|
-
return self._empty(start)
|
|
1150
|
-
try:
|
|
1151
|
-
notes, degraded = self._retrieve(ctx.user_input[:2000])
|
|
1152
|
-
except Exception:
|
|
1153
|
-
return self._empty(start)
|
|
1154
|
-
self._record(ctx, len(notes))
|
|
1155
|
-
if not notes:
|
|
1156
|
-
return self._empty(start)
|
|
1157
|
-
block = _format_kb_block(notes[: self._max_notes], degraded=degraded)
|
|
1158
|
-
ms = int((time.time() - start) * 1000)
|
|
1159
|
-
tag = f"[kb-context:{len(notes)}]"
|
|
1160
|
-
if degraded:
|
|
1161
|
-
tag = f"[kb-context:{len(notes)} degraded=keyword]"
|
|
1162
|
-
return LayerResult(
|
|
1163
|
-
layer_id=self.id,
|
|
1164
|
-
tag=tag,
|
|
1165
|
-
content=block,
|
|
1166
|
-
tokens_est=len(block.split()),
|
|
1167
|
-
compute_ms=ms,
|
|
1168
|
-
cached=False,
|
|
1169
|
-
)
|
|
719
|
+
# Lives in core/synapse/layers_kb.py (v4.21.0 split). Re-exported here so
|
|
720
|
+
# `core.synapse.layers` import paths and helper references keep working.
|
|
721
|
+
|
|
722
|
+
from core.synapse.layers_kb import ( # noqa: E402,F401
|
|
723
|
+
KBContextLayer,
|
|
724
|
+
_apply_grounding_policy,
|
|
725
|
+
_build_note_entry,
|
|
726
|
+
_extract_excerpt,
|
|
727
|
+
_extract_note_body,
|
|
728
|
+
_extract_title,
|
|
729
|
+
_extract_wikilinks,
|
|
730
|
+
_format_kb_block,
|
|
731
|
+
_frontmatter_marks_inferred,
|
|
732
|
+
_hit_is_inferred,
|
|
733
|
+
_jaccard,
|
|
734
|
+
_jaccard_fallback,
|
|
735
|
+
_KB_CONFIG_PATH,
|
|
736
|
+
_l25_feature_flag_on,
|
|
737
|
+
_load_fallback_notes,
|
|
738
|
+
_MAX_FALLBACK_NOTES,
|
|
739
|
+
_note_from_vector_hit,
|
|
740
|
+
_tokenize_for_jaccard,
|
|
741
|
+
_vector_search,
|
|
742
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Synapse layer base classes — LayerResult, PromptContext, Layer.
|
|
2
|
+
|
|
3
|
+
Extracted from layers.py (v4.21.0 split) so both layers.py and
|
|
4
|
+
layers_kb.py can import the contract without a circular dependency.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class LayerResult:
|
|
14
|
+
"""Result from computing a single layer."""
|
|
15
|
+
|
|
16
|
+
layer_id: str
|
|
17
|
+
tag: str # e.g., "[dept:dev]"
|
|
18
|
+
content: str # Full content for this layer
|
|
19
|
+
tokens_est: int # Estimated token count
|
|
20
|
+
compute_ms: int # Time to compute in milliseconds
|
|
21
|
+
cached: bool # Whether this was served from cache
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PromptContext:
|
|
26
|
+
"""Input context for layer computation."""
|
|
27
|
+
|
|
28
|
+
user_input: str = ""
|
|
29
|
+
cwd: str = ""
|
|
30
|
+
git_branch: str = ""
|
|
31
|
+
project_name: str = ""
|
|
32
|
+
project_stack: str = ""
|
|
33
|
+
active_agent: str = ""
|
|
34
|
+
runtime_id: str = "claude-code"
|
|
35
|
+
extra: dict[str, Any] = None
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if self.extra is None:
|
|
39
|
+
self.extra = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Layer(ABC):
|
|
43
|
+
"""Abstract base class for a Synapse context layer."""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def id(self) -> str:
|
|
48
|
+
"""Unique layer identifier (e.g., 'L0', 'L1')."""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def name(self) -> str:
|
|
53
|
+
"""Human-readable name."""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def cache_ttl(self) -> int:
|
|
57
|
+
"""Cache TTL in seconds. 0 = no caching."""
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def input_sensitive(self) -> bool:
|
|
62
|
+
"""True when compute() depends on ctx.user_input.
|
|
63
|
+
|
|
64
|
+
Input-sensitive layers get the prompt hashed into their cache
|
|
65
|
+
key — without it, a cached result from one prompt is served for
|
|
66
|
+
a DIFFERENT prompt within the TTL window (found 2026-07-09: L5
|
|
67
|
+
served 'hello' hints for an explicit '/dev feature' command,
|
|
68
|
+
defeating its own slash-suppression rule).
|
|
69
|
+
"""
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def session_sensitive(self) -> bool:
|
|
74
|
+
"""True when compute() has per-session side effects or output.
|
|
75
|
+
|
|
76
|
+
Session-sensitive layers get ctx.extra['session_id'] added to
|
|
77
|
+
their cache key — without it, a cache hit from one session
|
|
78
|
+
suppresses compute() for a concurrent session in the same cwd,
|
|
79
|
+
skipping that session's side effects (found 2026-07-09: L3.5's
|
|
80
|
+
KBSessionCache.store() never ran for the second session, so its
|
|
81
|
+
KB-injected state and overlap markers belonged to the first).
|
|
82
|
+
"""
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def priority(self) -> int:
|
|
87
|
+
"""Layer priority (lower = computed first)."""
|
|
88
|
+
return 50
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def compute(self, ctx: PromptContext) -> LayerResult:
|
|
92
|
+
"""Compute this layer's context.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
ctx: The prompt context with user input and environment.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
LayerResult with the computed context.
|
|
99
|
+
"""
|