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.
- package/VERSION +1 -1
- package/config/constitution.yaml +6 -0
- package/config/hooks/user-prompt-submit-v2.sh +33 -40
- package/core/budget/__init__.py +6 -0
- package/core/budget/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/budget/__pycache__/manager.cpython-313.pyc +0 -0
- package/core/budget/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/budget/manager.py +193 -0
- package/core/budget/schema.py +82 -0
- package/core/knowledge/__init__.py +6 -0
- package/core/knowledge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/chunker.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/indexer.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/chunker.py +121 -0
- package/core/knowledge/embedder.py +52 -0
- package/core/knowledge/indexer.py +97 -0
- package/core/knowledge/vector_store.py +213 -0
- package/core/obsidian/__init__.py +6 -0
- package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/templates.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
- package/core/obsidian/templates.py +76 -0
- package/core/obsidian/writer.py +148 -0
- package/core/orchestration/__init__.py +6 -0
- package/core/orchestration/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/orchestration/__pycache__/patterns.cpython-313.pyc +0 -0
- package/core/orchestration/__pycache__/protocol.cpython-313.pyc +0 -0
- package/core/orchestration/patterns.py +136 -0
- package/core/orchestration/protocol.py +96 -0
- package/core/runtime/__pycache__/subagent.cpython-313.pyc +0 -0
- package/core/runtime/subagent.py +5 -0
- package/core/squads/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/squads/schema.py +3 -0
- package/core/squads/templates/project-squad.yaml +28 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +5 -1
- package/core/synapse/layers.py +95 -9
- package/core/tasks/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/tasks/schema.py +7 -0
- package/core/workflow/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/workflow/engine.py +44 -0
- package/core/workflow/schema.py +1 -0
- package/departments/dev/agents/research-assistant.yaml +51 -0
- package/departments/kb/agents/data-collector.yaml +51 -0
- package/departments/ops/agents/doc-writer.yaml +51 -0
- package/departments/pm/agents/pm-director.yaml +1 -1
- package/installer/cli.js +36 -0
- package/installer/init.js +105 -0
- package/installer/migrate.js +4 -1
- package/package.json +1 -1
- package/pyproject.toml +5 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.0.
|
|
1
|
+
2.0.3
|
package/config/constitution.yaml
CHANGED
|
@@ -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
|
|
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
|
|
60
|
+
# ─── Try Python Synapse bridge first ────────────────────────────────────
|
|
39
61
|
python_result=""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
sys.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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"]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|