akernel-runtime 0.1.0__py3-none-any.whl

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 (40) hide show
  1. akernel_runtime-0.1.0.dist-info/METADATA +270 -0
  2. akernel_runtime-0.1.0.dist-info/RECORD +40 -0
  3. akernel_runtime-0.1.0.dist-info/WHEEL +5 -0
  4. akernel_runtime-0.1.0.dist-info/entry_points.txt +2 -0
  5. akernel_runtime-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. akernel_runtime-0.1.0.dist-info/licenses/NOTICE +4 -0
  7. akernel_runtime-0.1.0.dist-info/top_level.txt +1 -0
  8. context_kernel/__init__.py +4 -0
  9. context_kernel/__main__.py +5 -0
  10. context_kernel/agent_reports.py +188 -0
  11. context_kernel/benchmarks.py +493 -0
  12. context_kernel/budget.py +72 -0
  13. context_kernel/cli.py +2953 -0
  14. context_kernel/context.py +161 -0
  15. context_kernel/evals.py +347 -0
  16. context_kernel/global_memory.py +126 -0
  17. context_kernel/loop.py +1617 -0
  18. context_kernel/marketplace.py +194 -0
  19. context_kernel/marketplace_data/skills/context_budget.json +27 -0
  20. context_kernel/marketplace_data/skills/context_compaction.json +27 -0
  21. context_kernel/marketplace_data/skills/edit_file.json +27 -0
  22. context_kernel/marketplace_data/skills/index.json +66 -0
  23. context_kernel/marketplace_data/skills/long_task_planning.json +27 -0
  24. context_kernel/marketplace_data/skills/multi_file_bugfix.json +28 -0
  25. context_kernel/memory.py +515 -0
  26. context_kernel/models.py +144 -0
  27. context_kernel/planner.py +155 -0
  28. context_kernel/policy.py +271 -0
  29. context_kernel/project.py +317 -0
  30. context_kernel/providers.py +1264 -0
  31. context_kernel/report_costs.py +375 -0
  32. context_kernel/runner.py +78 -0
  33. context_kernel/skills.py +318 -0
  34. context_kernel/state_writer.py +108 -0
  35. context_kernel/storage.py +171 -0
  36. context_kernel/tasks.py +549 -0
  37. context_kernel/text.py +42 -0
  38. context_kernel/tokenizer.py +22 -0
  39. context_kernel/tools.py +544 -0
  40. context_kernel/verifier.py +77 -0
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .models import SelectedSkill, Skill
10
+ from .providers import get_provider
11
+ from .storage import Workspace
12
+ from .text import matched_terms
13
+ from .tokenizer import estimate_tokens
14
+
15
+
16
+ class SkillRegistry:
17
+ def __init__(self, workspace: Workspace):
18
+ self.workspace = workspace
19
+
20
+ def register(self, source: Path) -> Skill:
21
+ data = Workspace.read_json(source)
22
+ skill = Skill.from_dict(data)
23
+ destination = self.workspace.skills_dir / f"{skill.id}.json"
24
+ destination.parent.mkdir(parents=True, exist_ok=True)
25
+ shutil.copyfile(source, destination)
26
+ return skill
27
+
28
+ def all(self) -> list[Skill]:
29
+ skills: list[Skill] = []
30
+ for path in sorted(self.workspace.skills_dir.glob("*.json")):
31
+ skills.append(Skill.from_dict(Workspace.read_json(path)))
32
+ return skills
33
+
34
+ def get(self, skill_id: str) -> Skill:
35
+ path = self.workspace.skills_dir / f"{skill_id}.json"
36
+ if not path.exists():
37
+ raise KeyError(f"Unknown skill: {skill_id}")
38
+ return Skill.from_dict(Workspace.read_json(path))
39
+
40
+ def select(self, request: str, budget_tokens: int, limit: int = 3) -> list[SelectedSkill]:
41
+ candidates: list[SelectedSkill] = []
42
+ for skill in self.all():
43
+ haystack = " ".join(
44
+ [skill.id, skill.name, skill.summary, skill.intent, " ".join(skill.inputs), " ".join(skill.outputs)]
45
+ )
46
+ matches = matched_terms(request, haystack)
47
+ score = len(matches)
48
+ if score > 0:
49
+ candidates.append(
50
+ SelectedSkill(
51
+ skill=skill,
52
+ level="l1",
53
+ score=score,
54
+ reason=f"matched terms: {', '.join(matches)}",
55
+ matched_terms=matches,
56
+ )
57
+ )
58
+
59
+ if not candidates:
60
+ fallback = self.all()[:1]
61
+ candidates = [
62
+ SelectedSkill(
63
+ skill=skill,
64
+ level="l0",
65
+ score=0,
66
+ reason="fallback summary loaded because no skill matched",
67
+ matched_terms=[],
68
+ )
69
+ for skill in fallback
70
+ ]
71
+
72
+ selected = sorted(candidates, key=lambda item: item.score, reverse=True)[:limit]
73
+ remaining = budget_tokens
74
+ adjusted: list[SelectedSkill] = []
75
+ for item in selected:
76
+ level = item.level if item.score == 0 else "l2" if remaining > 350 else "l1"
77
+ rendered_tokens = estimate_tokens(item.skill.render_level(level))
78
+ if rendered_tokens > remaining and level == "l2":
79
+ level = "l1"
80
+ rendered_tokens = estimate_tokens(item.skill.render_level(level))
81
+ if rendered_tokens <= remaining:
82
+ adjusted.append(
83
+ SelectedSkill(
84
+ skill=item.skill,
85
+ level=level,
86
+ score=item.score,
87
+ reason=item.reason,
88
+ matched_terms=item.matched_terms,
89
+ )
90
+ )
91
+ remaining -= rendered_tokens
92
+ return adjusted
93
+
94
+
95
+ SECTION_ALIASES = {
96
+ "inputs": "inputs",
97
+ "input": "inputs",
98
+ "outputs": "outputs",
99
+ "output": "outputs",
100
+ "constraints": "constraints",
101
+ "constraint": "constraints",
102
+ "rules": "constraints",
103
+ "failure modes": "failure_modes",
104
+ "failure mode": "failure_modes",
105
+ "failures": "failure_modes",
106
+ "procedure": "procedure",
107
+ "workflow": "procedure",
108
+ "steps": "procedure",
109
+ "process": "procedure",
110
+ "examples": "examples",
111
+ "example": "examples",
112
+ "intent": "intent",
113
+ "summary": "summary",
114
+ }
115
+
116
+
117
+ def compile_markdown_skill(path: Path, skill_id: str | None = None) -> Skill:
118
+ text = path.read_text(encoding="utf-8")
119
+ title = first_heading(text) or title_from_filename(path)
120
+ sections = markdown_sections(text)
121
+ body_intro = intro_text(text)
122
+
123
+ name = title.strip()
124
+ inferred_id = skill_id or slugify(name)
125
+ summary = first_sentence(section_text(sections, "summary") or body_intro or name)
126
+ intent = first_sentence(section_text(sections, "intent") or summary)
127
+
128
+ data = {
129
+ "id": inferred_id,
130
+ "name": name,
131
+ "summary": summary,
132
+ "intent": intent,
133
+ "inputs": section_items(sections, "inputs"),
134
+ "outputs": section_items(sections, "outputs"),
135
+ "constraints": section_items(sections, "constraints"),
136
+ "failure_modes": section_items(sections, "failure_modes"),
137
+ "procedure": section_items(sections, "procedure") or fallback_procedure(body_intro),
138
+ "examples": section_items(sections, "examples"),
139
+ }
140
+ return Skill.from_dict(data)
141
+
142
+
143
+ def compile_markdown_skill_with_provider(
144
+ path: Path,
145
+ provider_name: str,
146
+ model: str | None = None,
147
+ base_url: str | None = None,
148
+ skill_id: str | None = None,
149
+ ) -> tuple[Skill, dict[str, Any]]:
150
+ markdown = path.read_text(encoding="utf-8")
151
+ provider = get_provider(provider_name, model=model, base_url=base_url)
152
+ requested_id = skill_id or slugify(first_heading(markdown) or title_from_filename(path))
153
+ packet = {
154
+ "request": "Compile this Markdown skill into Context Kernel skill JSON.",
155
+ "runtime": {
156
+ "instructions": [
157
+ "Return only valid JSON with no commentary.",
158
+ "Preserve concrete constraints, failure modes, and procedures.",
159
+ "Use concise strings. Do not invent capabilities absent from the Markdown.",
160
+ ]
161
+ },
162
+ "schema": {
163
+ "id": "string",
164
+ "name": "string",
165
+ "summary": "string",
166
+ "intent": "string",
167
+ "inputs": ["string"],
168
+ "outputs": ["string"],
169
+ "constraints": ["string"],
170
+ "failure_modes": ["string"],
171
+ "procedure": ["string"],
172
+ "examples": ["string"],
173
+ },
174
+ "requested_id": requested_id,
175
+ "source_markdown": markdown,
176
+ "budget": {"estimated_used": estimate_tokens(markdown)},
177
+ }
178
+ response = provider.run(packet)
179
+ data = extract_json_object(response.text)
180
+ data["id"] = skill_id or data.get("id") or requested_id
181
+ skill = Skill.from_dict(data)
182
+ metadata = {
183
+ "provider": provider.name,
184
+ "model": getattr(provider, "model", model),
185
+ "input_tokens": response.input_tokens,
186
+ "output_tokens": response.output_tokens,
187
+ "total_tokens": response.input_tokens + response.output_tokens,
188
+ }
189
+ return skill, metadata
190
+
191
+
192
+ def validate_skill_file(path: Path) -> dict[str, Any]:
193
+ skill = Skill.from_dict(Workspace.read_json(path))
194
+ levels = {level: estimate_tokens(skill.render_level(level)) for level in ["l0", "l1", "l2", "l3"]}
195
+ warnings: list[str] = []
196
+ if not skill.inputs:
197
+ warnings.append("inputs is empty")
198
+ if not skill.outputs:
199
+ warnings.append("outputs is empty")
200
+ if not skill.constraints:
201
+ warnings.append("constraints is empty")
202
+ if not skill.procedure:
203
+ warnings.append("procedure is empty")
204
+ return {
205
+ "ok": True,
206
+ "id": skill.id,
207
+ "name": skill.name,
208
+ "level_tokens": levels,
209
+ "warnings": warnings,
210
+ }
211
+
212
+
213
+ def inspect_skill(skill: Skill, budget: int) -> dict[str, Any]:
214
+ levels: list[dict[str, Any]] = []
215
+ selected = "l0"
216
+ for level in ["l0", "l1", "l2", "l3"]:
217
+ rendered = skill.render_level(level)
218
+ tokens = estimate_tokens(rendered)
219
+ fits = tokens <= budget
220
+ if fits:
221
+ selected = level
222
+ levels.append({"level": level, "tokens": tokens, "fits": fits, "content": rendered})
223
+ return {"id": skill.id, "budget": budget, "selected_level": selected, "levels": levels}
224
+
225
+
226
+ def markdown_sections(text: str) -> dict[str, list[str]]:
227
+ sections: dict[str, list[str]] = {}
228
+ current: str | None = None
229
+ for line in text.splitlines():
230
+ heading = parse_heading(line)
231
+ if heading:
232
+ current = SECTION_ALIASES.get(heading.lower())
233
+ if current:
234
+ sections.setdefault(current, [])
235
+ continue
236
+ if current:
237
+ sections[current].append(line)
238
+ return sections
239
+
240
+
241
+ def section_items(sections: dict[str, list[str]], name: str) -> list[str]:
242
+ lines = sections.get(name, [])
243
+ items: list[str] = []
244
+ for line in lines:
245
+ stripped = line.strip()
246
+ if not stripped:
247
+ continue
248
+ bullet = re.sub(r"^[-*]\s+", "", stripped)
249
+ bullet = re.sub(r"^\d+[.)]\s+", "", bullet)
250
+ items.append(bullet.strip())
251
+ return items
252
+
253
+
254
+ def section_text(sections: dict[str, list[str]], name: str) -> str:
255
+ return " ".join(line.strip() for line in sections.get(name, []) if line.strip())
256
+
257
+
258
+ def intro_text(text: str) -> str:
259
+ lines: list[str] = []
260
+ for line in text.splitlines():
261
+ if parse_heading(line):
262
+ if lines:
263
+ break
264
+ continue
265
+ stripped = line.strip()
266
+ if stripped:
267
+ lines.append(stripped)
268
+ return " ".join(lines)
269
+
270
+
271
+ def first_heading(text: str) -> str | None:
272
+ for line in text.splitlines():
273
+ heading = parse_heading(line)
274
+ if heading:
275
+ return heading
276
+ return None
277
+
278
+
279
+ def parse_heading(line: str) -> str | None:
280
+ match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*$", line)
281
+ return match.group(1).strip() if match else None
282
+
283
+
284
+ def first_sentence(text: str) -> str:
285
+ compact = " ".join(text.split())
286
+ if not compact:
287
+ return ""
288
+ match = re.match(r"^(.+?[.!?])\s", compact)
289
+ return match.group(1) if match else compact[:180]
290
+
291
+
292
+ def fallback_procedure(text: str) -> list[str]:
293
+ return [first_sentence(text)] if text else []
294
+
295
+
296
+ def title_from_filename(path: Path) -> str:
297
+ return path.stem.replace("_", " ").replace("-", " ").title()
298
+
299
+
300
+ def slugify(value: str) -> str:
301
+ slug = re.sub(r"[^a-zA-Z0-9]+", "_", value.lower()).strip("_")
302
+ return slug or "compiled_skill"
303
+
304
+
305
+ def extract_json_object(text: str) -> dict[str, Any]:
306
+ stripped = text.strip()
307
+ fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", stripped, flags=re.DOTALL)
308
+ if fenced:
309
+ stripped = fenced.group(1)
310
+ elif not stripped.startswith("{"):
311
+ start = stripped.find("{")
312
+ end = stripped.rfind("}")
313
+ if start >= 0 and end > start:
314
+ stripped = stripped[start : end + 1]
315
+ data = json.loads(stripped)
316
+ if not isinstance(data, dict):
317
+ raise ValueError("Provider did not return a JSON object.")
318
+ return data
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from .memory import MemoryStore
7
+ from .storage import Workspace
8
+
9
+
10
+ MARKER_KINDS = {
11
+ "decision": "decision",
12
+ "decided": "decision",
13
+ "fact": "fact",
14
+ "preference": "preference",
15
+ "project state": "project_state",
16
+ "project_state": "project_state",
17
+ "task state": "task_state",
18
+ "task_state": "task_state",
19
+ }
20
+ SECRET_PATTERNS = [
21
+ re.compile(r"sk-[A-Za-z0-9_\-]{8,}"),
22
+ re.compile(r"(?i)(api[_-]?key\s*[:=]\s*)[^\s]+"),
23
+ ]
24
+
25
+
26
+ class StateWriter:
27
+ def __init__(self, workspace: Workspace):
28
+ self.workspace = workspace
29
+ self.memory = MemoryStore(workspace)
30
+
31
+ def propose_from_trace(self, trace: dict[str, Any]) -> list[dict[str, Any]]:
32
+ candidates = [self._task_state_candidate(trace)]
33
+ if trace.get("verifier", {}).get("ok"):
34
+ candidates.extend(marker_candidates(trace))
35
+ return [candidate for candidate in candidates if candidate["text"]]
36
+
37
+ def write_from_trace(self, trace: dict[str, Any]) -> dict[str, Any]:
38
+ candidates = self.propose_from_trace(trace)
39
+ records = [
40
+ self.memory.add(candidate["kind"], candidate["text"], candidate["tags"])
41
+ for candidate in candidates
42
+ ]
43
+ return {
44
+ "enabled": True,
45
+ "candidate_count": len(candidates),
46
+ "written_count": len(records),
47
+ "records": [record.to_dict() for record in records],
48
+ }
49
+
50
+ def _task_state_candidate(self, trace: dict[str, Any]) -> dict[str, Any]:
51
+ response = trace.get("response", {})
52
+ verifier = trace.get("verifier", {})
53
+ status = "ok" if verifier.get("ok") else "failed"
54
+ text = (
55
+ f"Trace {trace.get('id')}: request '{compact(trace.get('request', ''))}' "
56
+ f"completed with provider {trace.get('provider')} status={status}; "
57
+ f"tokens={response.get('total_tokens', 0)}."
58
+ )
59
+ return {
60
+ "kind": "task_state",
61
+ "text": redact(text),
62
+ "tags": trace_tags(trace),
63
+ "source": "trace_summary",
64
+ }
65
+
66
+
67
+ def marker_candidates(trace: dict[str, Any]) -> list[dict[str, Any]]:
68
+ candidates: list[dict[str, Any]] = []
69
+ response_text = trace.get("response", {}).get("text", "")
70
+ for line in response_text.splitlines():
71
+ match = re.match(r"^\s*([A-Za-z _]+)\s*:\s*(.+?)\s*$", line)
72
+ if not match:
73
+ continue
74
+ marker = match.group(1).strip().casefold()
75
+ kind = MARKER_KINDS.get(marker)
76
+ if not kind:
77
+ continue
78
+ candidates.append(
79
+ {
80
+ "kind": kind,
81
+ "text": redact(compact(match.group(2))),
82
+ "tags": trace_tags(trace) + ["marker:" + marker.replace(" ", "_")],
83
+ "source": "response_marker",
84
+ }
85
+ )
86
+ return candidates
87
+
88
+
89
+ def trace_tags(trace: dict[str, Any]) -> list[str]:
90
+ tags = ["auto", "trace:" + str(trace.get("id", ""))]
91
+ provider = trace.get("provider")
92
+ if provider:
93
+ tags.append("provider:" + str(provider))
94
+ return tags
95
+
96
+
97
+ def compact(text: str, limit: int = 280) -> str:
98
+ normalized = " ".join(str(text).split())
99
+ if len(normalized) <= limit:
100
+ return normalized
101
+ return normalized[: limit - 3].rstrip() + "..."
102
+
103
+
104
+ def redact(text: str) -> str:
105
+ redacted = text
106
+ for pattern in SECRET_PATTERNS:
107
+ redacted = pattern.sub(lambda match: match.group(1) + "[REDACTED]" if match.groups() else "[REDACTED]", redacted)
108
+ return redacted
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Iterable
7
+
8
+
9
+ STATE_DIR = ".akernel"
10
+ DEFAULT_CONFIG_VERSION = 2
11
+ DEFAULT_RUNTIME_INSTRUCTIONS = [
12
+ "Use the smallest context packet that can answer the request.",
13
+ "Prefer structured memory and skill contracts over full history.",
14
+ "Report budget pressure and omitted context explicitly.",
15
+ ]
16
+ DEFAULT_COMMAND_POLICY = {
17
+ "allowed_roots": [
18
+ "akernel",
19
+ "akernel.exe",
20
+ "git",
21
+ "py",
22
+ "pytest",
23
+ "python",
24
+ "python.exe",
25
+ ],
26
+ "blocked_terms": [],
27
+ }
28
+ DEFAULT_CONFIG = {
29
+ "version": DEFAULT_CONFIG_VERSION,
30
+ "default_budget": 1200,
31
+ "runtime_instructions": DEFAULT_RUNTIME_INSTRUCTIONS,
32
+ "command_policy": DEFAULT_COMMAND_POLICY,
33
+ }
34
+
35
+
36
+ class Workspace:
37
+ def __init__(self, root: Path):
38
+ self.root = root.resolve()
39
+ self.state = self.root / STATE_DIR
40
+ self.skills_dir = self.state / "skills"
41
+ self.traces_dir = self.state / "traces"
42
+ self.tool_traces_dir = self.state / "tool_traces"
43
+ self.agent_runs_dir = self.state / "agent_runs"
44
+ self.tasks_dir = self.state / "tasks"
45
+ self.evals_dir = self.state / "evals"
46
+ self.benchmarks_dir = self.state / "benchmarks"
47
+ self.memory_file = self.state / "memory.jsonl"
48
+ self.memory_db = self.state / "memory.sqlite3"
49
+ self.config_file = self.state / "config.json"
50
+ self.project_file = self.state / "project.json"
51
+
52
+ def init(self) -> None:
53
+ self.root.mkdir(parents=True, exist_ok=True)
54
+ self.skills_dir.mkdir(parents=True, exist_ok=True)
55
+ self.traces_dir.mkdir(parents=True, exist_ok=True)
56
+ self.tool_traces_dir.mkdir(parents=True, exist_ok=True)
57
+ self.agent_runs_dir.mkdir(parents=True, exist_ok=True)
58
+ self.tasks_dir.mkdir(parents=True, exist_ok=True)
59
+ self.evals_dir.mkdir(parents=True, exist_ok=True)
60
+ self.benchmarks_dir.mkdir(parents=True, exist_ok=True)
61
+ if not self.memory_file.exists():
62
+ self.memory_file.write_text("", encoding="utf-8")
63
+ if not self.config_file.exists():
64
+ self.write_json(self.config_file, default_config())
65
+
66
+ def require_initialized(self) -> None:
67
+ if not self.state.exists():
68
+ raise FileNotFoundError(
69
+ f"Workspace is not initialized: {self.root}. Run `akernel init {self.root}` first."
70
+ )
71
+
72
+ def load_config(self) -> dict[str, Any]:
73
+ if not self.config_file.exists():
74
+ return default_config()
75
+ return normalize_config(self.read_json(self.config_file))
76
+
77
+ def save_config(self, data: dict[str, Any]) -> dict[str, Any]:
78
+ normalized = normalize_config(data)
79
+ self.write_json(self.config_file, normalized)
80
+ return normalized
81
+
82
+ @staticmethod
83
+ def read_json(path: Path) -> dict[str, Any]:
84
+ return json.loads(path.read_text(encoding="utf-8-sig"))
85
+
86
+ @staticmethod
87
+ def write_json(path: Path, data: dict[str, Any]) -> None:
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
90
+
91
+ @staticmethod
92
+ def append_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> None:
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ with path.open("a", encoding="utf-8") as handle:
95
+ for row in rows:
96
+ handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
97
+
98
+ @staticmethod
99
+ def read_jsonl(path: Path) -> list[dict[str, Any]]:
100
+ if not path.exists():
101
+ return []
102
+ rows: list[dict[str, Any]] = []
103
+ for line in path.read_text(encoding="utf-8").splitlines():
104
+ if line.strip():
105
+ rows.append(json.loads(line))
106
+ return rows
107
+
108
+
109
+ def default_config() -> dict[str, Any]:
110
+ return deepcopy(DEFAULT_CONFIG)
111
+
112
+
113
+ def normalize_config(data: dict[str, Any] | None) -> dict[str, Any]:
114
+ merged = merge_dicts(default_config(), data if isinstance(data, dict) else {})
115
+ merged["version"] = max(DEFAULT_CONFIG_VERSION, safe_int(merged.get("version"), DEFAULT_CONFIG_VERSION))
116
+ merged["default_budget"] = safe_int(merged.get("default_budget"), int(DEFAULT_CONFIG["default_budget"]))
117
+ merged["runtime_instructions"] = dedupe_strings(
118
+ merged.get("runtime_instructions"),
119
+ fallback=list(DEFAULT_RUNTIME_INSTRUCTIONS),
120
+ )
121
+
122
+ command_policy = merged.get("command_policy")
123
+ if not isinstance(command_policy, dict):
124
+ command_policy = {}
125
+ command_policy["allowed_roots"] = dedupe_strings(
126
+ command_policy.get("allowed_roots"),
127
+ fallback=list(DEFAULT_COMMAND_POLICY["allowed_roots"]),
128
+ casefold=True,
129
+ )
130
+ command_policy["blocked_terms"] = dedupe_strings(
131
+ command_policy.get("blocked_terms"),
132
+ fallback=[],
133
+ casefold=True,
134
+ )
135
+ merged["command_policy"] = command_policy
136
+ return merged
137
+
138
+
139
+ def merge_dicts(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
140
+ merged = deepcopy(base)
141
+ for key, value in overrides.items():
142
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
143
+ merged[key] = merge_dicts(merged[key], value)
144
+ else:
145
+ merged[key] = deepcopy(value)
146
+ return merged
147
+
148
+
149
+ def dedupe_strings(values: Any, *, fallback: list[str], casefold: bool = False) -> list[str]:
150
+ source = values if isinstance(values, list) else fallback
151
+ result: list[str] = []
152
+ seen: set[str] = set()
153
+ for item in source:
154
+ if not isinstance(item, str):
155
+ continue
156
+ text = item.strip()
157
+ if not text:
158
+ continue
159
+ key = text.casefold() if casefold else text
160
+ if key in seen:
161
+ continue
162
+ seen.add(key)
163
+ result.append(text.casefold() if casefold else text)
164
+ return result or list(fallback)
165
+
166
+
167
+ def safe_int(value: Any, fallback: int) -> int:
168
+ try:
169
+ return int(value)
170
+ except (TypeError, ValueError):
171
+ return fallback