arkaos 2.0.1 → 2.0.3

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 (55) hide show
  1. package/VERSION +1 -1
  2. package/config/constitution.yaml +6 -0
  3. package/config/hooks/user-prompt-submit-v2.sh +33 -40
  4. package/core/budget/__init__.py +6 -0
  5. package/core/budget/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/core/budget/__pycache__/manager.cpython-313.pyc +0 -0
  7. package/core/budget/__pycache__/schema.cpython-313.pyc +0 -0
  8. package/core/budget/manager.py +193 -0
  9. package/core/budget/schema.py +82 -0
  10. package/core/knowledge/__init__.py +6 -0
  11. package/core/knowledge/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/knowledge/__pycache__/chunker.cpython-313.pyc +0 -0
  13. package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
  14. package/core/knowledge/__pycache__/indexer.cpython-313.pyc +0 -0
  15. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  16. package/core/knowledge/chunker.py +121 -0
  17. package/core/knowledge/embedder.py +52 -0
  18. package/core/knowledge/indexer.py +97 -0
  19. package/core/knowledge/vector_store.py +213 -0
  20. package/core/obsidian/__init__.py +6 -0
  21. package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
  22. package/core/obsidian/__pycache__/templates.cpython-313.pyc +0 -0
  23. package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
  24. package/core/obsidian/templates.py +76 -0
  25. package/core/obsidian/writer.py +148 -0
  26. package/core/orchestration/__init__.py +6 -0
  27. package/core/orchestration/__pycache__/__init__.cpython-313.pyc +0 -0
  28. package/core/orchestration/__pycache__/patterns.cpython-313.pyc +0 -0
  29. package/core/orchestration/__pycache__/protocol.cpython-313.pyc +0 -0
  30. package/core/orchestration/patterns.py +136 -0
  31. package/core/orchestration/protocol.py +96 -0
  32. package/core/runtime/__pycache__/subagent.cpython-313.pyc +0 -0
  33. package/core/runtime/subagent.py +5 -0
  34. package/core/squads/__pycache__/schema.cpython-313.pyc +0 -0
  35. package/core/squads/schema.py +3 -0
  36. package/core/squads/templates/project-squad.yaml +28 -0
  37. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  38. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  39. package/core/synapse/engine.py +5 -1
  40. package/core/synapse/layers.py +95 -9
  41. package/core/tasks/__pycache__/schema.cpython-313.pyc +0 -0
  42. package/core/tasks/schema.py +7 -0
  43. package/core/workflow/__pycache__/engine.cpython-313.pyc +0 -0
  44. package/core/workflow/__pycache__/schema.cpython-313.pyc +0 -0
  45. package/core/workflow/engine.py +44 -0
  46. package/core/workflow/schema.py +1 -0
  47. package/departments/dev/agents/research-assistant.yaml +51 -0
  48. package/departments/kb/agents/data-collector.yaml +51 -0
  49. package/departments/ops/agents/doc-writer.yaml +51 -0
  50. package/departments/pm/agents/pm-director.yaml +1 -1
  51. package/installer/cli.js +36 -0
  52. package/installer/init.js +105 -0
  53. package/installer/migrate.js +4 -1
  54. package/package.json +1 -1
  55. package/pyproject.toml +5 -1
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.1
1
+ 2.0.3
@@ -60,6 +60,8 @@ enforcement_levels:
60
60
 
61
61
  quality_gate:
62
62
  description: "Mandatory pre-delivery review. Nothing ships without APPROVED verdict."
63
+ trigger: "After the last execution phase, before delivery to user"
64
+ frequency: "Once per workflow execution, not per phase"
63
65
  agents:
64
66
  orchestrator:
65
67
  id: cqo-marta
@@ -118,6 +120,10 @@ enforcement_levels:
118
120
  - id: complexity-assessment
119
121
  rule: "Assess task complexity before starting. Route to appropriate workflow tier."
120
122
 
123
+ - id: communication-standard
124
+ rule: "Bottom-line first output. Lead with answer, then why, then how. Confidence tags on assessments."
125
+ enforcement: "See config/standards/communication.md for full standard"
126
+
121
127
  tier_hierarchy:
122
128
  description: "Agent authority levels inspired by SpaceX/Google/Anthropic org structures"
123
129
  tiers:
@@ -7,6 +7,17 @@
7
7
 
8
8
  input=$(cat)
9
9
 
10
+ # ─── V1 Migration Detection ─────────────────────────────────────────────
11
+ V1_PATHS=("$HOME/.claude/skills/arka-os" "$HOME/.claude/skills/arkaos")
12
+ MIGRATION_MARKER="$HOME/.arkaos/migrated-from-v1"
13
+
14
+ for v1_path in "${V1_PATHS[@]}"; do
15
+ if [ -d "$v1_path" ] && [ ! -f "$MIGRATION_MARKER" ]; then
16
+ echo "{\"additionalContext\": \"[MIGRATION] ArkaOS v1 detected at $v1_path. Run: npx arkaos migrate — This will backup v1, preserve your data, and install v2. See: https://github.com/andreagroferreira/arka-os#install\"}"
17
+ exit 0
18
+ fi
19
+ done
20
+
10
21
  # ─── Performance Timing ──────────────────────────────────────────────────
11
22
  _HOOK_START_NS=$(date +%s%N 2>/dev/null || echo "0")
12
23
  _hook_ms() {
@@ -19,7 +30,18 @@ _hook_ms() {
19
30
  }
20
31
 
21
32
  # ─── Paths ───────────────────────────────────────────────────────────────
22
- ARKAOS_ROOT="${ARKA_OS:-$HOME/.claude/skills/arkaos}"
33
+ # Resolve ARKAOS_ROOT: env var → .repo-path → npm package → fallback
34
+ if [ -n "${ARKAOS_ROOT:-}" ]; then
35
+ : # already set
36
+ elif [ -f "$HOME/.arkaos/.repo-path" ]; then
37
+ ARKAOS_ROOT=$(cat "$HOME/.arkaos/.repo-path")
38
+ elif [ -d "$HOME/.arkaos" ]; then
39
+ ARKAOS_ROOT="$HOME/.arkaos"
40
+ else
41
+ ARKAOS_ROOT="${ARKA_OS:-$HOME/.claude/skills/arkaos}"
42
+ fi
43
+ export ARKAOS_ROOT
44
+
23
45
  CACHE_DIR="/tmp/arkaos-context-cache"
24
46
  CACHE_TTL=300 # Constitution cache: 5 minutes
25
47
 
@@ -35,46 +57,17 @@ if [ -z "$user_input" ]; then
35
57
  user_input=$(echo "$input" | head -c 2000)
36
58
  fi
37
59
 
38
- # ─── Try Python Synapse engine first ─────────────────────────────────────
60
+ # ─── Try Python Synapse bridge first ────────────────────────────────────
39
61
  python_result=""
40
- if command -v python3 &>/dev/null; then
41
- python_result=$(python3 -c "
42
- import sys, os
43
- sys.path.insert(0, '${ARKAOS_ROOT}')
44
- try:
45
- from core.synapse.engine import create_default_engine
46
- from core.synapse.layers import PromptContext
47
- from core.governance.constitution import load_constitution
48
- import subprocess
49
-
50
- # Load constitution for L0
51
- const_path = '${ARKAOS_ROOT}/config/constitution.yaml'
52
- compressed = ''
53
- if os.path.exists(const_path):
54
- c = load_constitution(const_path)
55
- compressed = c.compress_for_context()
56
-
57
- # Detect git branch
58
- branch = ''
59
- try:
60
- branch = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
61
- capture_output=True, text=True, timeout=2).stdout.strip()
62
- except Exception:
63
- pass
64
-
65
- # Create engine and inject
66
- engine = create_default_engine(constitution_compressed=compressed)
67
- ctx = PromptContext(
68
- user_input='''${user_input}''',
69
- cwd=os.getcwd(),
70
- git_branch=branch,
71
- )
72
- result = engine.inject(ctx)
73
- print(result.context_string)
74
- except Exception as e:
75
- print(f'[arkaos:error] {e}', file=sys.stderr)
76
- print('')
77
- " 2>/dev/null)
62
+ BRIDGE_SCRIPT="${ARKAOS_ROOT}/scripts/synapse-bridge.py"
63
+
64
+ if command -v python3 &>/dev/null && [ -f "$BRIDGE_SCRIPT" ]; then
65
+ bridge_output=$(echo "{\"user_input\":$(echo "$user_input" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || echo '""')}" \
66
+ | ARKAOS_ROOT="$ARKAOS_ROOT" python3 "$BRIDGE_SCRIPT" --root "$ARKAOS_ROOT" 2>/dev/null)
67
+
68
+ if [ -n "$bridge_output" ]; then
69
+ python_result=$(echo "$bridge_output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('context_string',''))" 2>/dev/null)
70
+ fi
78
71
  fi
79
72
 
80
73
  # ─── Fallback: Bash-only context (if Python unavailable) ────────────────
@@ -0,0 +1,6 @@
1
+ """Token budget system — tracking and enforcement for agent operations."""
2
+
3
+ from core.budget.schema import BudgetConfig, BudgetUsage, BudgetSummary, TierBudget
4
+ from core.budget.manager import BudgetManager
5
+
6
+ __all__ = ["BudgetConfig", "BudgetUsage", "BudgetSummary", "TierBudget", "BudgetManager"]
@@ -0,0 +1,193 @@
1
+ """Budget manager — track, enforce, and report on token budgets."""
2
+
3
+ import json
4
+ from datetime import date, datetime
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from core.budget.schema import BudgetConfig, BudgetSummary, BudgetUsage, TierBudget
9
+
10
+
11
+ class BudgetManager:
12
+ """Manages token budget allocation, tracking, and enforcement.
13
+
14
+ Budgets are tier-based with monthly reset. Usage is persisted to JSON.
15
+ Tier 0 agents have unlimited budgets. Other tiers need Tier 0 approval
16
+ when usage exceeds the approval threshold (default 80%).
17
+ """
18
+
19
+ def __init__(self, storage_path: str | Path = "", config: BudgetConfig | None = None) -> None:
20
+ self._config = config or BudgetConfig()
21
+ self._usages: list[BudgetUsage] = []
22
+ self._counter: int = 0
23
+ self._storage_path = Path(storage_path) if storage_path else None
24
+ if self._storage_path and self._storage_path.exists():
25
+ self._load()
26
+
27
+ def record_usage(
28
+ self,
29
+ agent_id: str,
30
+ tokens: int,
31
+ tier: int = 2,
32
+ department: str = "",
33
+ workflow_id: str = "",
34
+ task_id: str = "",
35
+ description: str = "",
36
+ approved_by: str = "",
37
+ ) -> BudgetUsage:
38
+ """Record a token usage event."""
39
+ self._counter += 1
40
+ usage = BudgetUsage(
41
+ id=f"usage-{self._counter:06d}",
42
+ agent_id=agent_id,
43
+ department=department,
44
+ tier=tier,
45
+ tokens=tokens,
46
+ workflow_id=workflow_id,
47
+ task_id=task_id,
48
+ description=description,
49
+ timestamp=datetime.now().isoformat(),
50
+ approved_by=approved_by,
51
+ )
52
+ self._usages.append(usage)
53
+ self._save()
54
+ return usage
55
+
56
+ def get_period_usage(self, tier: int, department: str = "") -> int:
57
+ """Get total tokens used this billing period for a tier/department."""
58
+ period_start = self._current_period_start()
59
+ total = 0
60
+ for u in self._usages:
61
+ if u.tier != tier:
62
+ continue
63
+ if department and u.department != department:
64
+ continue
65
+ if u.timestamp and u.timestamp >= period_start.isoformat():
66
+ total += u.tokens
67
+ return total
68
+
69
+ def get_remaining(self, tier: int, department: str = "") -> int:
70
+ """Get remaining tokens for this period. Returns -1 for unlimited."""
71
+ budget = self._config.get_tier_budget(tier)
72
+ if budget.is_unlimited:
73
+ return -1
74
+ used = self.get_period_usage(tier, department)
75
+ return max(0, budget.monthly_tokens - used)
76
+
77
+ def check_budget(self, tier: int, estimated_tokens: int, department: str = "") -> bool:
78
+ """Check if there's enough budget for an operation. True = OK."""
79
+ budget = self._config.get_tier_budget(tier)
80
+ if budget.is_unlimited:
81
+ return True
82
+
83
+ # Check per-task limit
84
+ if budget.per_task_max > 0 and estimated_tokens > budget.per_task_max:
85
+ return False
86
+
87
+ # Check remaining monthly budget
88
+ remaining = self.get_remaining(tier, department)
89
+ return estimated_tokens <= remaining
90
+
91
+ def needs_approval(self, tier: int, department: str = "") -> bool:
92
+ """Check if usage has exceeded the approval threshold."""
93
+ budget = self._config.get_tier_budget(tier)
94
+ if budget.is_unlimited:
95
+ return False
96
+ used = self.get_period_usage(tier, department)
97
+ return used >= (budget.monthly_tokens * budget.approval_threshold)
98
+
99
+ def get_summary(self, tier: int, department: str = "") -> BudgetSummary:
100
+ """Get a complete budget summary for a tier/department."""
101
+ budget = self._config.get_tier_budget(tier)
102
+ used = self.get_period_usage(tier, department)
103
+ period_start = self._current_period_start()
104
+
105
+ if budget.is_unlimited:
106
+ return BudgetSummary(
107
+ tier=tier,
108
+ department=department,
109
+ period_start=period_start.isoformat(),
110
+ allocated=0,
111
+ used=used,
112
+ remaining=-1,
113
+ percent_used=0,
114
+ is_unlimited=True,
115
+ usage_count=self._count_period_usages(tier, department),
116
+ )
117
+
118
+ remaining = max(0, budget.monthly_tokens - used)
119
+ percent = (used / budget.monthly_tokens * 100) if budget.monthly_tokens > 0 else 0
120
+ overruns = sum(
121
+ 1 for u in self._usages
122
+ if u.tier == tier
123
+ and (not department or u.department == department)
124
+ and u.timestamp >= period_start.isoformat()
125
+ and u.is_overrun_approved
126
+ )
127
+
128
+ return BudgetSummary(
129
+ tier=tier,
130
+ department=department,
131
+ period_start=period_start.isoformat(),
132
+ allocated=budget.monthly_tokens,
133
+ used=used,
134
+ remaining=remaining,
135
+ percent_used=round(percent, 1),
136
+ is_unlimited=False,
137
+ needs_approval=self.needs_approval(tier, department),
138
+ usage_count=self._count_period_usages(tier, department),
139
+ overruns=overruns,
140
+ )
141
+
142
+ def reset_monthly(self) -> int:
143
+ """Archive old usages and start a new billing period. Returns archived count."""
144
+ period_start = self._current_period_start()
145
+ old = [u for u in self._usages if u.timestamp < period_start.isoformat()]
146
+ self._usages = [u for u in self._usages if u.timestamp >= period_start.isoformat()]
147
+ self._save()
148
+ return len(old)
149
+
150
+ def _current_period_start(self) -> date:
151
+ """Get the start of the current billing period."""
152
+ today = date.today()
153
+ day = self._config.billing_start_day
154
+ if today.day >= day:
155
+ return date(today.year, today.month, day)
156
+ # Previous month
157
+ month = today.month - 1
158
+ year = today.year
159
+ if month < 1:
160
+ month = 12
161
+ year -= 1
162
+ return date(year, month, day)
163
+
164
+ def _count_period_usages(self, tier: int, department: str = "") -> int:
165
+ period_start = self._current_period_start()
166
+ return sum(
167
+ 1 for u in self._usages
168
+ if u.tier == tier
169
+ and (not department or u.department == department)
170
+ and u.timestamp >= period_start.isoformat()
171
+ )
172
+
173
+ def _save(self) -> None:
174
+ if self._storage_path is None:
175
+ return
176
+ self._storage_path.parent.mkdir(parents=True, exist_ok=True)
177
+ data = {
178
+ "counter": self._counter,
179
+ "usages": [u.model_dump(mode="json") for u in self._usages],
180
+ }
181
+ with open(self._storage_path, "w") as f:
182
+ json.dump(data, f, indent=2)
183
+
184
+ def _load(self) -> None:
185
+ if self._storage_path is None or not self._storage_path.exists():
186
+ return
187
+ content = self._storage_path.read_text().strip()
188
+ if not content:
189
+ return
190
+ data = json.loads(content)
191
+ self._counter = data.get("counter", 0)
192
+ for udata in data.get("usages", []):
193
+ self._usages.append(BudgetUsage.model_validate(udata))
@@ -0,0 +1,82 @@
1
+ """Budget schema — token allocation, usage tracking, and summaries."""
2
+
3
+ from datetime import date, datetime
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class TierBudget(BaseModel):
10
+ """Token budget for a single agent tier."""
11
+ tier: int
12
+ monthly_tokens: int # 0 = unlimited
13
+ per_task_max: int # 0 = unlimited
14
+ approval_threshold: float = 0.8 # Needs Tier 0 approval at this % used
15
+
16
+ @property
17
+ def is_unlimited(self) -> bool:
18
+ return self.monthly_tokens == 0
19
+
20
+
21
+ # Default tier budgets (configurable via BudgetConfig)
22
+ DEFAULT_TIER_BUDGETS = {
23
+ 0: TierBudget(tier=0, monthly_tokens=0, per_task_max=0), # Unlimited
24
+ 1: TierBudget(tier=1, monthly_tokens=5_000_000, per_task_max=500_000),
25
+ 2: TierBudget(tier=2, monthly_tokens=2_000_000, per_task_max=200_000),
26
+ 3: TierBudget(tier=3, monthly_tokens=1_000_000, per_task_max=100_000),
27
+ }
28
+
29
+
30
+ class BudgetConfig(BaseModel):
31
+ """Budget configuration for the system."""
32
+ tier_budgets: dict[int, TierBudget] = Field(default_factory=lambda: dict(DEFAULT_TIER_BUDGETS))
33
+ billing_start_day: int = 1 # Day of month billing resets
34
+
35
+ def get_tier_budget(self, tier: int) -> TierBudget:
36
+ return self.tier_budgets.get(tier, DEFAULT_TIER_BUDGETS.get(tier, TierBudget(tier=tier, monthly_tokens=1_000_000, per_task_max=100_000)))
37
+
38
+
39
+ class BudgetUsage(BaseModel):
40
+ """A single token usage record."""
41
+ id: str
42
+ agent_id: str
43
+ department: str = ""
44
+ tier: int = 2
45
+ tokens: int = 0
46
+ workflow_id: str = ""
47
+ task_id: str = ""
48
+ description: str = ""
49
+ timestamp: str = ""
50
+ approved_by: str = "" # Tier 0 agent who approved overrun
51
+
52
+ @property
53
+ def is_overrun_approved(self) -> bool:
54
+ return bool(self.approved_by)
55
+
56
+
57
+ class BudgetSummary(BaseModel):
58
+ """Current budget status for a tier or department."""
59
+ tier: int
60
+ department: str = ""
61
+ period_start: str = ""
62
+ period_end: str = ""
63
+ allocated: int = 0 # Monthly allocation
64
+ used: int = 0 # Tokens used this period
65
+ remaining: int = 0 # Tokens remaining
66
+ percent_used: float = 0.0 # 0-100
67
+ is_unlimited: bool = False
68
+ needs_approval: bool = False # >80% threshold
69
+ usage_count: int = 0 # Number of operations this period
70
+ overruns: int = 0 # Number of approved overruns
71
+
72
+ @property
73
+ def status(self) -> str:
74
+ if self.is_unlimited:
75
+ return "UNLIMITED"
76
+ if self.percent_used >= 100:
77
+ return "EXCEEDED"
78
+ if self.needs_approval:
79
+ return "APPROVAL_REQUIRED"
80
+ if self.percent_used >= 50:
81
+ return "MODERATE"
82
+ return "HEALTHY"
@@ -0,0 +1,6 @@
1
+ """Knowledge system — vector store, chunking, embedding, and retrieval."""
2
+
3
+ from core.knowledge.chunker import chunk_markdown
4
+ from core.knowledge.vector_store import VectorStore
5
+
6
+ __all__ = ["VectorStore", "chunk_markdown"]
@@ -0,0 +1,121 @@
1
+ """Markdown chunker — split documents into embeddable chunks.
2
+
3
+ Splits on paragraph boundaries, respects heading structure,
4
+ and maintains overlap for context continuity.
5
+ """
6
+
7
+ import re
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass
12
+ class Chunk:
13
+ """A text chunk ready for embedding."""
14
+ text: str
15
+ heading: str = "" # Current heading context
16
+ index: int = 0 # Position in document
17
+ source: str = "" # Source file path
18
+
19
+ @property
20
+ def token_estimate(self) -> int:
21
+ return len(self.text.split())
22
+
23
+
24
+ def chunk_markdown(
25
+ content: str,
26
+ max_tokens: int = 512,
27
+ overlap_tokens: int = 50,
28
+ source: str = "",
29
+ ) -> list[Chunk]:
30
+ """Split markdown content into chunks at paragraph boundaries.
31
+
32
+ Args:
33
+ content: Markdown text to chunk.
34
+ max_tokens: Maximum tokens per chunk.
35
+ overlap_tokens: Token overlap between consecutive chunks.
36
+ source: Source file path for metadata.
37
+
38
+ Returns:
39
+ List of Chunk objects.
40
+ """
41
+ # Strip frontmatter
42
+ body = content
43
+ if content.startswith("---"):
44
+ end = content.find("---", 3)
45
+ if end != -1:
46
+ body = content[end + 3:].strip()
47
+
48
+ # Split into paragraphs (double newline) preserving headings
49
+ blocks = re.split(r'\n\n+', body)
50
+ blocks = [b.strip() for b in blocks if b.strip()]
51
+
52
+ chunks: list[Chunk] = []
53
+ current_heading = ""
54
+ current_text = ""
55
+ current_tokens = 0
56
+
57
+ for block in blocks:
58
+ # Track headings
59
+ heading_match = re.match(r'^(#{1,6})\s+(.+)', block)
60
+ if heading_match:
61
+ current_heading = heading_match.group(2)
62
+
63
+ block_tokens = len(block.split())
64
+
65
+ # If single block exceeds max, split it
66
+ if block_tokens > max_tokens:
67
+ if current_text:
68
+ chunks.append(Chunk(
69
+ text=current_text.strip(),
70
+ heading=current_heading,
71
+ index=len(chunks),
72
+ source=source,
73
+ ))
74
+ current_text = ""
75
+ current_tokens = 0
76
+
77
+ # Split large block by sentences
78
+ sentences = re.split(r'(?<=[.!?])\s+', block)
79
+ for sentence in sentences:
80
+ sent_tokens = len(sentence.split())
81
+ if current_tokens + sent_tokens > max_tokens and current_text:
82
+ chunks.append(Chunk(
83
+ text=current_text.strip(),
84
+ heading=current_heading,
85
+ index=len(chunks),
86
+ source=source,
87
+ ))
88
+ # Overlap: keep last few words
89
+ words = current_text.split()
90
+ current_text = " ".join(words[-overlap_tokens:]) + " " if len(words) > overlap_tokens else ""
91
+ current_tokens = len(current_text.split())
92
+ current_text += sentence + " "
93
+ current_tokens += sent_tokens
94
+ continue
95
+
96
+ # Check if adding this block exceeds limit
97
+ if current_tokens + block_tokens > max_tokens and current_text:
98
+ chunks.append(Chunk(
99
+ text=current_text.strip(),
100
+ heading=current_heading,
101
+ index=len(chunks),
102
+ source=source,
103
+ ))
104
+ # Overlap
105
+ words = current_text.split()
106
+ current_text = " ".join(words[-overlap_tokens:]) + " " if len(words) > overlap_tokens else ""
107
+ current_tokens = len(current_text.split())
108
+
109
+ current_text += block + "\n\n"
110
+ current_tokens += block_tokens
111
+
112
+ # Final chunk
113
+ if current_text.strip():
114
+ chunks.append(Chunk(
115
+ text=current_text.strip(),
116
+ heading=current_heading,
117
+ index=len(chunks),
118
+ source=source,
119
+ ))
120
+
121
+ return chunks
@@ -0,0 +1,52 @@
1
+ """Embedding wrapper — local embeddings via fastembed.
2
+
3
+ Graceful degradation: if fastembed is not installed, returns None
4
+ and the vector store falls back to keyword matching.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ # Lazy import — fastembed is optional
10
+ _model = None
11
+ _model_name = "BAAI/bge-small-en-v1.5" # 384 dims, fast, good quality
12
+ EMBEDDING_DIMS = 384
13
+
14
+
15
+ def get_model():
16
+ """Get or create the embedding model (lazy singleton)."""
17
+ global _model
18
+ if _model is None:
19
+ try:
20
+ from fastembed import TextEmbedding
21
+ _model = TextEmbedding(_model_name)
22
+ except ImportError:
23
+ return None
24
+ return _model
25
+
26
+
27
+ def embed(text: str) -> Optional[list[float]]:
28
+ """Embed a single text. Returns None if fastembed unavailable."""
29
+ model = get_model()
30
+ if model is None:
31
+ return None
32
+ results = list(model.embed([text]))
33
+ return results[0].tolist() if results else None
34
+
35
+
36
+ def embed_batch(texts: list[str]) -> Optional[list[list[float]]]:
37
+ """Embed multiple texts. Returns None if fastembed unavailable."""
38
+ if not texts:
39
+ return []
40
+ model = get_model()
41
+ if model is None:
42
+ return None
43
+ return [emb.tolist() for emb in model.embed(texts)]
44
+
45
+
46
+ def is_available() -> bool:
47
+ """Check if embedding model is available."""
48
+ try:
49
+ from fastembed import TextEmbedding
50
+ return True
51
+ except ImportError:
52
+ return False