caudate-cli 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 (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
core/skills.py ADDED
@@ -0,0 +1,398 @@
1
+ """Skills — markdown-defined capability packs for Cognos.
2
+
3
+ Compatible with the open `vercel-labs/skills` ecosystem: a "skill" is
4
+ a directory containing a `SKILL.md` file with YAML frontmatter
5
+ (`name`, `description`) and markdown instructions.
6
+
7
+ Cognos scans three locations:
8
+ - `~/.cognos/skills/` — Cognos-specific
9
+ - `~/.claude/skills/` — Claude Code's standard location (compat)
10
+ - `<repo>/skills/` — bundled with this checkout
11
+
12
+ At chat time, the names + descriptions are injected into the system
13
+ prompt as a *table of contents*. The LLM calls `LoadSkill(name)` to
14
+ pull the full body of a skill it deems relevant. This keeps the
15
+ default prompt size bounded even with dozens of skills installed.
16
+
17
+ Skills are pure markdown — no runtime, no code execution. They're
18
+ prompt-engineering instructions the LLM follows.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # Default scan locations. Order matters: later entries override earlier
31
+ # ones if names collide (later wins → repo skills override globals).
32
+ DEFAULT_SKILL_DIRS: tuple[Path, ...] = (
33
+ Path.home() / ".claude" / "skills",
34
+ Path.home() / ".cognos" / "skills",
35
+ Path("/home/raveuk/cognos/skills"),
36
+ )
37
+
38
+
39
+ @dataclass
40
+ class Skill:
41
+ """One markdown-defined capability pack."""
42
+ name: str
43
+ description: str
44
+ body: str # the markdown instructions (no frontmatter)
45
+ path: Path # source SKILL.md
46
+ source_dir: Path # which scan dir it came from
47
+
48
+ @property
49
+ def header(self) -> str:
50
+ """One-line summary used in the system-prompt table of contents."""
51
+ desc = self.description or "(no description)"
52
+ return f"- **{self.name}** — {desc[:160]}"
53
+
54
+
55
+ # ---------------------------------------------------------------------
56
+ # Parsing
57
+ # ---------------------------------------------------------------------
58
+
59
+
60
+ def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
61
+ """Split YAML-frontmatter markdown into (metadata, body).
62
+
63
+ We do a minimal hand-roll instead of pulling in PyYAML — skills
64
+ only use scalar string fields (name, description), not nested
65
+ structures. This avoids adding a runtime dependency.
66
+ """
67
+ if not text.startswith("---"):
68
+ return {}, text
69
+ end_marker = text.find("\n---", 3)
70
+ if end_marker < 0:
71
+ return {}, text
72
+ fm_block = text[3:end_marker].strip()
73
+ body_start = end_marker + 4
74
+ if body_start < len(text) and text[body_start] == "\n":
75
+ body_start += 1
76
+ body = text[body_start:].strip()
77
+
78
+ meta: dict[str, str] = {}
79
+ for line in fm_block.splitlines():
80
+ line = line.rstrip()
81
+ if not line or line.startswith("#"):
82
+ continue
83
+ # Naive key:value, supports quoted scalars + multi-word values
84
+ if ":" in line:
85
+ k, _, v = line.partition(":")
86
+ v = v.strip()
87
+ # Strip surrounding quotes
88
+ if (len(v) >= 2 and v[0] == v[-1]
89
+ and v[0] in ('"', "'")):
90
+ v = v[1:-1]
91
+ meta[k.strip()] = v
92
+ return meta, body
93
+
94
+
95
+ def _load_skill(skill_md: Path, source_dir: Path) -> Skill | None:
96
+ try:
97
+ text = skill_md.read_text()
98
+ except OSError as e:
99
+ logger.warning(f"Failed to read {skill_md}: {e}")
100
+ return None
101
+ meta, body = _parse_frontmatter(text)
102
+ name = (meta.get("name") or skill_md.parent.name).strip()
103
+ description = (meta.get("description") or "").strip()
104
+ if not name:
105
+ return None
106
+ return Skill(
107
+ name=name,
108
+ description=description,
109
+ body=body,
110
+ path=skill_md,
111
+ source_dir=source_dir,
112
+ )
113
+
114
+
115
+ # ---------------------------------------------------------------------
116
+ # Registry
117
+ # ---------------------------------------------------------------------
118
+
119
+
120
+ @dataclass
121
+ class SkillRegistry:
122
+ """In-memory index of all loaded skills, keyed by name."""
123
+
124
+ skills: dict[str, Skill] = field(default_factory=dict)
125
+ scanned_dirs: list[Path] = field(default_factory=list)
126
+
127
+ def reload(self, dirs: tuple[Path, ...] = DEFAULT_SKILL_DIRS) -> None:
128
+ """Rescan all configured directories. Idempotent."""
129
+ self.skills.clear()
130
+ self.scanned_dirs = []
131
+ for root in dirs:
132
+ self.scanned_dirs.append(root)
133
+ if not root.exists():
134
+ continue
135
+ for skill_md in root.rglob("SKILL.md"):
136
+ skill = _load_skill(skill_md, root)
137
+ if skill is None:
138
+ continue
139
+ # Last-wins for duplicate names
140
+ self.skills[skill.name] = skill
141
+
142
+ def list_names(self) -> list[str]:
143
+ return sorted(self.skills.keys())
144
+
145
+ def get(self, name: str) -> Skill | None:
146
+ return self.skills.get(name)
147
+
148
+ def render_toc(self) -> str:
149
+ """Format the skills as a system-prompt table of contents.
150
+
151
+ Returns empty string if no skills are loaded — avoids polluting
152
+ the prompt with "no skills installed" boilerplate.
153
+ """
154
+ if not self.skills:
155
+ return ""
156
+ lines = [
157
+ "## Available skills",
158
+ "",
159
+ "Skills are markdown capability packs. Use `LoadSkill(name=...)` "
160
+ "to read the full instructions for any of these when relevant:",
161
+ "",
162
+ ]
163
+ for skill in sorted(self.skills.values(), key=lambda s: s.name.lower()):
164
+ lines.append(skill.header)
165
+ return "\n".join(lines)
166
+
167
+ def find_relevant(
168
+ self,
169
+ user_text: str,
170
+ top_k: int = 2,
171
+ min_score: float = 0.18,
172
+ ) -> list[tuple["Skill", float]]:
173
+ """Score skills against a free-form user prompt; return the top-K.
174
+
175
+ Scoring strategy: split everything (name, description, prompt)
176
+ into alnum-only tokens (so 'deploy-to-vercel' → {'deploy',
177
+ 'to', 'vercel'} after stop-word filtering), then score on
178
+ TWO axes:
179
+
180
+ 1. user_coverage — what fraction of the user's distinctive
181
+ tokens does this skill cover? Catches "user mentioned
182
+ vercel and deploy → vercel skill is relevant".
183
+ 2. name_match — what fraction of the skill's NAME tokens
184
+ showed up in the user prompt? Strong signal because the
185
+ name is a curated label.
186
+
187
+ Final score = user_coverage * 0.5 + name_match * 0.5.
188
+ Default threshold 0.18 catches strong matches without firing
189
+ on every passing keyword.
190
+ """
191
+ if not user_text or not self.skills:
192
+ return []
193
+
194
+ def _tokens(s: str) -> set[str]:
195
+ # Split on any non-alnum character — gives us the components
196
+ # of hyphen-words like 'deploy-to-vercel' separately.
197
+ import re
198
+ tokens = re.findall(r"[a-z0-9]+", s.lower())
199
+ return {t for t in tokens if len(t) > 2 and t not in _STOP}
200
+
201
+ user_tokens = _tokens(user_text)
202
+ if not user_tokens:
203
+ return []
204
+
205
+ scored: list[tuple[Skill, float]] = []
206
+ for skill in self.skills.values():
207
+ name_tokens = _tokens(skill.name)
208
+ desc_tokens = _tokens(skill.description)
209
+ all_skill_tokens = name_tokens | desc_tokens
210
+ if not all_skill_tokens:
211
+ continue
212
+ overlap = user_tokens & all_skill_tokens
213
+ if not overlap:
214
+ continue
215
+ user_coverage = len(overlap) / len(user_tokens)
216
+ name_match = (len(user_tokens & name_tokens)
217
+ / max(1, len(name_tokens)))
218
+ score = user_coverage * 0.5 + name_match * 0.5
219
+ if score >= min_score:
220
+ scored.append((skill, score))
221
+ scored.sort(key=lambda p: p[1], reverse=True)
222
+ return scored[:top_k]
223
+
224
+ def render_active_skills(self, user_text: str, top_k: int = 2) -> str:
225
+ """Auto-activate the top-K skills relevant to a user prompt and
226
+ return their FULL bodies for system-prompt injection.
227
+
228
+ Returns empty string if nothing matches. The chat path injects
229
+ this output below the TOC so the LLM both knows about all
230
+ skills (TOC) AND has the full instructions for the most-likely-
231
+ relevant ones already in-context (no LoadSkill round-trip).
232
+ """
233
+ matches = self.find_relevant(user_text, top_k=top_k)
234
+ if not matches:
235
+ return ""
236
+ parts = [
237
+ "## Active skills (auto-selected for this turn)",
238
+ "",
239
+ "These skills look relevant to the user's prompt. **Follow "
240
+ "their instructions when they apply** — they are more "
241
+ "specific and current than your training defaults. Other "
242
+ "skills are listed in the TOC above; LoadSkill if needed.",
243
+ ]
244
+ for skill, score in matches:
245
+ parts.append(f"\n### {skill.name} _(relevance ≈ {score:.2f})_")
246
+ if skill.description:
247
+ parts.append(f"_{skill.description}_\n")
248
+ parts.append(skill.body)
249
+ return "\n".join(parts)
250
+
251
+
252
+ # Common stop words, dropped from token sets so they don't drive bogus
253
+ # matches. Not exhaustive — just the worst offenders.
254
+ _STOP: frozenset[str] = frozenset({
255
+ "the", "and", "for", "with", "from", "that", "this", "have",
256
+ "was", "were", "will", "are", "you", "your", "want", "would",
257
+ "could", "should", "what", "when", "where", "how", "why", "who",
258
+ "can", "all", "any", "but", "not", "they", "them", "their", "his",
259
+ "her", "she", "him", "its", "our", "out", "use", "using", "into",
260
+ })
261
+
262
+
263
+ # ---------------------------------------------------------------------
264
+ # Module-level singleton (lazy)
265
+ # ---------------------------------------------------------------------
266
+
267
+
268
+ _registry: SkillRegistry | None = None
269
+
270
+
271
+ def get_skills() -> SkillRegistry:
272
+ global _registry
273
+ if _registry is None:
274
+ _registry = SkillRegistry()
275
+ _registry.reload()
276
+ logger.info(
277
+ f"SkillRegistry loaded {len(_registry.skills)} skill(s) "
278
+ f"from {len(_registry.scanned_dirs)} dirs"
279
+ )
280
+ return _registry
281
+
282
+
283
+ def reload_skills() -> SkillRegistry:
284
+ """Force re-scan of all skill directories. Use after `npx skills add ...`."""
285
+ global _registry
286
+ if _registry is None:
287
+ _registry = SkillRegistry()
288
+ _registry.reload()
289
+ return _registry
290
+
291
+
292
+ # ---------------------------------------------------------------------
293
+ # Lockfile — pin skill content hashes so silent edits are caught
294
+ # ---------------------------------------------------------------------
295
+ #
296
+ # LocalForge calls this its "skills lockfile". It records a SHA-256 of
297
+ # every SKILL.md so a `cognos skills verify` run can flag any skill
298
+ # that drifted between deploys (someone hand-edited the markdown,
299
+ # upstream pushed an update, etc.). Tier 3 in the LocalForge plan but
300
+ # valuable: it makes "what skills did this build of Cognos ship with?"
301
+ # answerable. The lock is written to data/skills.lock.json.
302
+
303
+ import hashlib
304
+ import json
305
+ from datetime import datetime, timezone
306
+
307
+ LOCKFILE_PATH = Path(__file__).resolve().parents[1] / "data" / "skills.lock.json"
308
+
309
+
310
+ def _hash_skill(skill: Skill) -> str:
311
+ """Stable SHA-256 of the SKILL.md raw bytes."""
312
+ try:
313
+ return hashlib.sha256(skill.path.read_bytes()).hexdigest()
314
+ except OSError:
315
+ return ""
316
+
317
+
318
+ def lock_skills(reg: SkillRegistry | None = None,
319
+ lock_path: Path | None = None,
320
+ dirs: tuple[Path, ...] | None = None) -> dict:
321
+ """Compute hashes for every loaded skill and write the lockfile.
322
+
323
+ Returns the dict that was written.
324
+ """
325
+ if reg is None:
326
+ if dirs is not None:
327
+ reg = SkillRegistry()
328
+ reg.reload(dirs=dirs)
329
+ else:
330
+ reg = get_skills()
331
+ if lock_path is None:
332
+ lock_path = LOCKFILE_PATH
333
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
334
+
335
+ entries: list[dict] = []
336
+ for name in sorted(reg.skills):
337
+ skill = reg.skills[name]
338
+ entries.append({
339
+ "name": skill.name,
340
+ "description": skill.description,
341
+ "path": str(skill.path),
342
+ "source_dir": str(skill.source_dir),
343
+ "sha256": _hash_skill(skill),
344
+ "size_bytes": skill.path.stat().st_size if skill.path.exists() else 0,
345
+ })
346
+ payload = {
347
+ "generated_at": datetime.now(timezone.utc).isoformat(),
348
+ "count": len(entries),
349
+ "skills": entries,
350
+ }
351
+ lock_path.write_text(json.dumps(payload, indent=2))
352
+ return payload
353
+
354
+
355
+ def verify_skills(lock_path: Path | None = None,
356
+ dirs: tuple[Path, ...] | None = None) -> dict:
357
+ """Compare the live filesystem to a lockfile.
358
+
359
+ Returns a report dict::
360
+
361
+ {
362
+ "ok": bool,
363
+ "missing": [name, …] # in lock but no longer on disk
364
+ "added": [name, …] # on disk but not in lock
365
+ "changed": [name, …] # hash mismatch
366
+ "unchanged": int,
367
+ }
368
+ """
369
+ if lock_path is None:
370
+ lock_path = LOCKFILE_PATH
371
+ if not lock_path.exists():
372
+ return {"ok": False, "error": f"lockfile not found: {lock_path}"}
373
+
374
+ lock = json.loads(lock_path.read_text())
375
+ locked = {e["name"]: e for e in lock.get("skills", [])}
376
+
377
+ reg = SkillRegistry()
378
+ reg.reload(dirs=dirs) if dirs is not None else reg.reload()
379
+ live = {name: _hash_skill(skill) for name, skill in reg.skills.items()}
380
+
381
+ missing = [n for n in locked if n not in live]
382
+ added = [n for n in live if n not in locked]
383
+ changed = [
384
+ n for n in locked
385
+ if n in live and live[n] != locked[n].get("sha256")
386
+ ]
387
+ unchanged = sum(
388
+ 1 for n in locked
389
+ if n in live and live[n] == locked[n].get("sha256")
390
+ )
391
+ return {
392
+ "ok": not (missing or added or changed),
393
+ "missing": missing,
394
+ "added": added,
395
+ "changed": changed,
396
+ "unchanged": unchanged,
397
+ "lock_path": str(lock_path),
398
+ }