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.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +2 -1
  4. package/config/claude-agents/marta-cqo.md +9 -0
  5. package/config/standards/{claude-md-overlays → stack-rules}/laravel.md +11 -0
  6. package/config/standards/{claude-md-overlays → stack-rules}/node.md +10 -1
  7. package/config/standards/{claude-md-overlays → stack-rules}/nuxt.md +12 -1
  8. package/config/standards/stack-rules/php.md +13 -0
  9. package/config/standards/{claude-md-overlays → stack-rules}/python.md +5 -0
  10. package/config/standards/stack-rules/react.md +14 -0
  11. package/config/standards/stack-rules/vue.md +13 -0
  12. package/core/cognition/evolve.py +299 -0
  13. package/core/cognition/evolve_cli.py +55 -0
  14. package/core/governance/stop_lint.py +266 -0
  15. package/core/governance/stop_lint_telemetry.py +132 -0
  16. package/core/governance/tool_loop_check.py +111 -0
  17. package/core/hooks/stop.py +73 -22
  18. package/core/runtime/pricing.py +53 -19
  19. package/core/synapse/layers.py +49 -476
  20. package/core/synapse/layers_base.py +99 -0
  21. package/core/synapse/layers_kb.py +395 -0
  22. package/core/sync/content_syncer.py +79 -10
  23. package/dashboard/app/components/AgentDependencyGraph.vue +5 -5
  24. package/dashboard/app/components/AgentEditDrawer.vue +120 -52
  25. package/dashboard/app/components/AgentSuggestionsCard.vue +4 -2
  26. package/dashboard/app/components/ConfirmDialog.vue +2 -2
  27. package/dashboard/app/components/DashboardState.vue +5 -3
  28. package/dashboard/app/components/GlobalSearch.vue +11 -7
  29. package/dashboard/app/components/KeyboardShortcutsHelp.vue +8 -6
  30. package/dashboard/app/components/KnowledgeSourcesList.vue +13 -9
  31. package/dashboard/app/components/MarkdownEditor.vue +1 -1
  32. package/dashboard/app/components/NotificationsBell.vue +8 -4
  33. package/dashboard/app/components/OnboardingTour.vue +24 -11
  34. package/dashboard/app/components/PersonaCloneDialog.vue +15 -8
  35. package/dashboard/app/components/PersonaWizard.vue +105 -72
  36. package/dashboard/app/components/SidebarFavoritesWidget.vue +5 -5
  37. package/dashboard/app/components/SidebarStatsWidget.vue +3 -1
  38. package/dashboard/app/components/Terminal.vue +9 -8
  39. package/dashboard/app/components/TextDiff.vue +27 -15
  40. package/dashboard/app/composables/useActivityFeed.ts +3 -3
  41. package/dashboard/app/composables/useApi.ts +4 -2
  42. package/dashboard/app/composables/useConfirmDialog.ts +1 -1
  43. package/dashboard/app/composables/useFavorites.ts +8 -8
  44. package/dashboard/app/composables/useTerminalThemes.ts +13 -13
  45. package/dashboard/app/composables/useThemeColor.ts +7 -4
  46. package/dashboard/app/pages/agents/[id].vue +319 -159
  47. package/dashboard/app/pages/agents/compare.vue +152 -52
  48. package/dashboard/app/pages/agents/index.vue +68 -55
  49. package/dashboard/app/pages/agents/new.vue +125 -58
  50. package/dashboard/app/pages/audit.vue +13 -6
  51. package/dashboard/app/pages/budget.vue +20 -18
  52. package/dashboard/app/pages/commands.vue +26 -22
  53. package/dashboard/app/pages/departments/[dept].vue +67 -31
  54. package/dashboard/app/pages/departments/compare.vue +85 -27
  55. package/dashboard/app/pages/departments/index.vue +31 -7
  56. package/dashboard/app/pages/health.vue +19 -12
  57. package/dashboard/app/pages/knowledge/index.vue +190 -78
  58. package/dashboard/app/pages/models.vue +74 -28
  59. package/dashboard/app/pages/personas/[id].vue +322 -219
  60. package/dashboard/app/pages/personas/archetypes.vue +20 -6
  61. package/dashboard/app/pages/personas/compare-with-agent.vue +109 -39
  62. package/dashboard/app/pages/personas/compare.vue +196 -124
  63. package/dashboard/app/pages/personas/index.vue +67 -60
  64. package/dashboard/app/pages/personas/new.vue +1 -1
  65. package/dashboard/app/pages/settings.vue +1 -0
  66. package/dashboard/app/pages/tasks.vue +80 -38
  67. package/dashboard/app/pages/trash.vue +11 -9
  68. package/dashboard/app/pages/workflows.vue +29 -19
  69. package/dashboard/nuxt.config.ts +2 -1
  70. package/dashboard/package.json +5 -0
  71. package/departments/kb/skills/doc-extraction/SKILL.md +99 -0
  72. package/departments/kb/skills/doc-redaction/SKILL.md +103 -0
  73. package/departments/kb/skills/knowledge-ops/SKILL.md +88 -0
  74. package/departments/ops/skills/github-ops/SKILL.md +89 -0
  75. package/departments/ops/skills/terminal-ops/SKILL.md +83 -0
  76. package/installer/index.js +1 -1
  77. package/knowledge/commands-registry.json +16 -3
  78. package/knowledge/skills-manifest.json +66 -1
  79. package/package.json +1 -1
  80. package/pyproject.toml +1 -1
@@ -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 abc import ABC, abstractmethod
24
- from dataclasses import dataclass
25
- from pathlib import Path
26
- from typing import Optional, Any
27
-
28
-
29
- @dataclass
30
- class LayerResult:
31
- """Result from computing a single layer."""
32
-
33
- layer_id: str
34
- tag: str # e.g., "[dept:dev]"
35
- content: str # Full content for this layer
36
- tokens_est: int # Estimated token count
37
- compute_ms: int # Time to compute in milliseconds
38
- cached: bool # Whether this was served from cache
39
-
40
-
41
- @dataclass
42
- class PromptContext:
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
- _WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:\|[^\]]+)?\]\]")
793
- _FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
794
- _KB_CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
795
- # Cap fallback-note scanning to avoid O(vault size) blow-ups on large
796
- # Obsidian vaults. The cap is above any realistic top-N retrieval need
797
- # (Jaccard ranks the top few notes; scanning 2000 sorted-by-name first
798
- # is plenty — see `_load_fallback_notes`) while still bounding worst-case latency.
799
- _MAX_FALLBACK_NOTES = 2000
800
- _KB_STOPWORDS: frozenset[str] = frozenset({
801
- "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of",
802
- "with", "by", "from", "as", "is", "was", "are", "were", "be", "been", "being",
803
- "have", "has", "had", "do", "does", "did", "will", "would", "should", "could",
804
- "may", "might", "must", "can", "this", "that", "these", "those", "it", "its",
805
- "about", "into", "over", "under", "up", "down", "out", "than", "then", "so",
806
- "if", "because", "while", "where", "when", "what", "which", "who", "whom",
807
- "how", "why", "all", "some", "any", "no", "not", "very", "just", "also",
808
- })
809
-
810
-
811
- def _l25_feature_flag_on() -> bool:
812
- if os.environ.get("ARKA_BYPASS_L25", "").strip() == "1":
813
- return False
814
- if not _KB_CONFIG_PATH.exists():
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
+ """