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
nn/vision.py ADDED
@@ -0,0 +1,352 @@
1
+ """Vision backends for Caudate.
2
+
3
+ Two embedders ship, dispatched via `make_vision_encoder(backend, ...)`:
4
+
5
+ - **CLIP** (default) — `clip-ViT-B-32` via sentence-transformers.
6
+ 512-D, ~150M params, ~600MB. Fast, light, semantically aligned to
7
+ text. Good default.
8
+
9
+ - **InternVL2** — OpenGVLab/InternVL2-{8B,26B}. Extracts the vision
10
+ tower's projected features (4096-D for 8B). Way richer than CLIP
11
+ — can pick up text in screenshots, code structure, diagrams. But
12
+ loading the model takes ~16GB VRAM (8B) or ~50GB (26B in bf16).
13
+
14
+ Both expose the same `encode_paths(paths) -> (N, embed_dim) tensor`
15
+ interface, so the rest of Cognos doesn't care which one's running.
16
+
17
+ To swap backends, edit `nn/config.py::vision_backend` and bump
18
+ `vision_embed_dim` to match (CLIP=512, InternVL2-8B=4096). The new
19
+ checkpoint will then need fresh training — old weights have a
20
+ different vision_proj input dim.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import logging
27
+ import os
28
+ from pathlib import Path
29
+ from typing import Any, Protocol
30
+
31
+ # Block transformers' TF integration before sentence-transformers loads.
32
+ os.environ.setdefault("USE_TF", "0")
33
+
34
+ import numpy as np
35
+ import torch
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ DEFAULT_CLIP_MODEL = "clip-ViT-B-32"
40
+ CLIP_EMBED_DIM = 512
41
+ INTERNVL_DEFAULT_MODEL = "OpenGVLab/InternVL2-8B"
42
+ INTERNVL_EMBED_DIM = 4096
43
+ INTERNVL_IMAGE_SIZE = 448
44
+
45
+ # IMAGENET / InternViT normalization
46
+ _INTERNVL_MEAN = (0.485, 0.456, 0.406)
47
+ _INTERNVL_STD = (0.229, 0.224, 0.225)
48
+
49
+
50
+ # --- Protocol --------------------------------------------------------
51
+
52
+
53
+ class BaseVisionEncoder(Protocol):
54
+ """Common shape: paths in, fixed-dim tensor out."""
55
+
56
+ embed_dim: int
57
+
58
+ def encode_paths(self, paths: list[str]) -> torch.Tensor:
59
+ ...
60
+
61
+
62
+ # --- CLIP (light, default) ------------------------------------------
63
+
64
+
65
+ class CLIPVisionEncoder:
66
+ """CLIP wrapper. Lazy. Batched. Cached per-path."""
67
+
68
+ embed_dim = CLIP_EMBED_DIM
69
+
70
+ def __init__(self, model_name: str = DEFAULT_CLIP_MODEL):
71
+ self.model_name = model_name
72
+ self._model = None
73
+ self._fake = False
74
+ self._cache: dict[str, torch.Tensor] = {}
75
+
76
+ def _load(self) -> None:
77
+ if self._model is not None or self._fake:
78
+ return
79
+ try:
80
+ from sentence_transformers import SentenceTransformer
81
+ from PIL import Image # noqa: F401
82
+ self._model = SentenceTransformer(self.model_name)
83
+ real_dim = self._model.get_sentence_embedding_dimension()
84
+ if isinstance(real_dim, int) and real_dim != self.embed_dim:
85
+ logger.warning(
86
+ f"CLIP dim {real_dim} != configured {self.embed_dim} — "
87
+ "retrain Caudate if you swap models"
88
+ )
89
+ except Exception as e:
90
+ logger.warning(
91
+ f"CLIP unavailable ({e}); vision channel will use deterministic "
92
+ "hash embeddings — Caudate's vision will be degraded but the "
93
+ "pipeline keeps running."
94
+ )
95
+ self._fake = True
96
+
97
+ def encode_paths(self, paths: list[str]) -> torch.Tensor:
98
+ self._load()
99
+ result_slots: list[torch.Tensor | None] = [None] * len(paths)
100
+ to_load: list[tuple[int, str]] = []
101
+
102
+ for i, p in enumerate(paths):
103
+ if not p:
104
+ result_slots[i] = torch.zeros(self.embed_dim, dtype=torch.float32)
105
+ continue
106
+ cached = self._cache.get(p)
107
+ if cached is not None:
108
+ result_slots[i] = cached
109
+ else:
110
+ to_load.append((i, p))
111
+
112
+ if to_load:
113
+ if self._fake or self._model is None:
114
+ for i, p in to_load:
115
+ vec = _hash_embed(p, self.embed_dim)
116
+ self._cache[p] = vec
117
+ result_slots[i] = vec
118
+ else:
119
+ from PIL import Image
120
+ images: list[Any] = []
121
+ valid: list[int] = []
122
+ for i, p in to_load:
123
+ try:
124
+ img = Image.open(p).convert("RGB")
125
+ images.append(img)
126
+ valid.append(i)
127
+ except Exception as e:
128
+ logger.debug(f"image open failed for {p}: {e}")
129
+ result_slots[i] = torch.zeros(self.embed_dim, dtype=torch.float32)
130
+ if images:
131
+ with torch.no_grad():
132
+ arr = self._model.encode(
133
+ images, convert_to_numpy=True, normalize_embeddings=True,
134
+ )
135
+ for j, idx in enumerate(valid):
136
+ vec = torch.from_numpy(arr[j]).float()
137
+ path = paths[idx]
138
+ self._cache[path] = vec
139
+ result_slots[idx] = vec
140
+
141
+ out = [
142
+ v if v is not None else torch.zeros(self.embed_dim, dtype=torch.float32)
143
+ for v in result_slots
144
+ ]
145
+ return torch.stack(out, dim=0)
146
+
147
+
148
+ # --- InternVL2 (rich, heavier) --------------------------------------
149
+
150
+
151
+ class InternVLVisionEncoder:
152
+ """OpenGVLab/InternVL2-{8B,26B} vision tower as a feature extractor.
153
+
154
+ Loads the full InternVL2 model (the vision tower isn't packaged
155
+ separately) but only ever runs forward through the vision encoder
156
+ + MLP projector — the LLM half never gets a forward pass. Output
157
+ is the projected vision features, mean-pooled across patches into
158
+ a single vector per image (4096-D for the 8B variant).
159
+
160
+ First call is slow (~30s to load + 16GB VRAM for 8B in bf16). After
161
+ that, encoding is fast and per-image embeddings are cached.
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ model_name: str = INTERNVL_DEFAULT_MODEL,
167
+ embed_dim: int = INTERNVL_EMBED_DIM,
168
+ device: str | None = None,
169
+ dtype: str = "bfloat16",
170
+ ):
171
+ self.model_name = model_name
172
+ self.embed_dim = embed_dim
173
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
174
+ self.dtype_name = dtype
175
+ self._model = None
176
+ self._cache: dict[str, torch.Tensor] = {}
177
+
178
+ def _load(self) -> None:
179
+ if self._model is not None:
180
+ return
181
+ try:
182
+ from transformers import AutoModel
183
+ except ImportError as e:
184
+ raise RuntimeError(
185
+ "InternVL backend needs `transformers`. Install it first."
186
+ ) from e
187
+
188
+ torch_dtype = getattr(torch, self.dtype_name)
189
+ logger.info(
190
+ f"Loading {self.model_name} (vision tower + projector only). "
191
+ f"~16GB VRAM in bf16 for InternVL2-8B."
192
+ )
193
+ self._model = AutoModel.from_pretrained(
194
+ self.model_name,
195
+ torch_dtype=torch_dtype,
196
+ low_cpu_mem_usage=True,
197
+ trust_remote_code=True,
198
+ ).eval()
199
+ self._model.to(self.device)
200
+ logger.info(f"InternVL vision encoder ready on {self.device}.")
201
+
202
+ def _preprocess(self, paths: list[str]) -> tuple[torch.Tensor, list[int]]:
203
+ """Resize+normalize to (N, 3, 448, 448). Returns (tensor, valid_indices)."""
204
+ from PIL import Image
205
+ from torchvision import transforms
206
+
207
+ tfm = transforms.Compose([
208
+ transforms.Resize(
209
+ (INTERNVL_IMAGE_SIZE, INTERNVL_IMAGE_SIZE),
210
+ interpolation=transforms.InterpolationMode.BICUBIC,
211
+ ),
212
+ transforms.ToTensor(),
213
+ transforms.Normalize(mean=_INTERNVL_MEAN, std=_INTERNVL_STD),
214
+ ])
215
+ tensors: list[torch.Tensor] = []
216
+ valid: list[int] = []
217
+ for i, p in enumerate(paths):
218
+ try:
219
+ img = Image.open(p).convert("RGB")
220
+ tensors.append(tfm(img))
221
+ valid.append(i)
222
+ except Exception as e:
223
+ logger.debug(f"image open failed for {p}: {e}")
224
+ if not tensors:
225
+ return torch.empty(0), []
226
+ batch = torch.stack(tensors, dim=0)
227
+ return batch, valid
228
+
229
+ @torch.no_grad()
230
+ def encode_paths(self, paths: list[str]) -> torch.Tensor:
231
+ result_slots: list[torch.Tensor | None] = [None] * len(paths)
232
+ to_load: list[tuple[int, str]] = []
233
+
234
+ for i, p in enumerate(paths):
235
+ if not p:
236
+ result_slots[i] = torch.zeros(self.embed_dim, dtype=torch.float32)
237
+ continue
238
+ cached = self._cache.get(p)
239
+ if cached is not None:
240
+ result_slots[i] = cached
241
+ else:
242
+ to_load.append((i, p))
243
+
244
+ if to_load:
245
+ self._load()
246
+ assert self._model is not None
247
+ # Just the paths we need to embed
248
+ paths_to_load = [p for _, p in to_load]
249
+ pixel, valid_local = self._preprocess(paths_to_load)
250
+ for k, (orig_idx, _) in enumerate(to_load):
251
+ if k not in valid_local:
252
+ result_slots[orig_idx] = torch.zeros(self.embed_dim, dtype=torch.float32)
253
+ if pixel.numel() > 0:
254
+ pixel = pixel.to(self.device, dtype=getattr(torch, self.dtype_name))
255
+ # InternVL2 exposes `extract_feature` which returns the
256
+ # post-projector vision features (B, num_patches, 4096).
257
+ # If unavailable, fall back to the raw vision_model output
258
+ # and project ourselves via the model's mlp1.
259
+ try:
260
+ feats = self._model.extract_feature(pixel)
261
+ except AttributeError:
262
+ out = self._model.vision_model(pixel_values=pixel)
263
+ feats = out.last_hidden_state
264
+ if hasattr(self._model, "mlp1"):
265
+ feats = self._model.mlp1(feats)
266
+ # Mean-pool across patches → (B, embed_dim)
267
+ pooled = feats.mean(dim=1).float().cpu()
268
+ # L2-normalize for stable training
269
+ pooled = pooled / pooled.norm(dim=-1, keepdim=True).clamp(min=1e-8)
270
+ # Map back to result slots
271
+ vi = 0
272
+ for k, (orig_idx, p) in enumerate(to_load):
273
+ if k in valid_local:
274
+ vec = pooled[vi]
275
+ self._cache[p] = vec
276
+ result_slots[orig_idx] = vec
277
+ vi += 1
278
+
279
+ out_list = [
280
+ v if v is not None else torch.zeros(self.embed_dim, dtype=torch.float32)
281
+ for v in result_slots
282
+ ]
283
+ return torch.stack(out_list, dim=0)
284
+
285
+
286
+ # --- Factory --------------------------------------------------------
287
+
288
+
289
+ def make_vision_encoder(
290
+ backend: str = "clip",
291
+ model_name: str | None = None,
292
+ **kwargs: Any,
293
+ ) -> BaseVisionEncoder:
294
+ """Pick a vision backend by name.
295
+
296
+ backend = 'clip' → CLIPVisionEncoder
297
+ backend = 'internvl' → InternVLVisionEncoder
298
+ backend = 'auto' → infer from model_name
299
+ """
300
+ if backend == "auto":
301
+ if model_name and "intern" in model_name.lower():
302
+ backend = "internvl"
303
+ else:
304
+ backend = "clip"
305
+
306
+ b = backend.lower()
307
+ if b == "clip":
308
+ return CLIPVisionEncoder(model_name=model_name or DEFAULT_CLIP_MODEL)
309
+ if b in ("internvl", "internvl2"):
310
+ return InternVLVisionEncoder(
311
+ model_name=model_name or INTERNVL_DEFAULT_MODEL,
312
+ **kwargs,
313
+ )
314
+ raise ValueError(f"Unknown vision backend: {backend!r}")
315
+
316
+
317
+ # --- Backward compat: the StateEncoder still imports `VisionEncoder` -
318
+
319
+
320
+ class VisionEncoder:
321
+ """Public facade — dispatches to the configured backend.
322
+
323
+ Behavior preserved for older code that imported this class directly:
324
+ constructing `VisionEncoder(model_name)` keeps using CLIP unless
325
+ you pass `backend="internvl"`.
326
+ """
327
+
328
+ def __init__(
329
+ self,
330
+ model_name: str = DEFAULT_CLIP_MODEL,
331
+ backend: str = "auto",
332
+ **kwargs: Any,
333
+ ):
334
+ self._impl = make_vision_encoder(
335
+ backend=backend, model_name=model_name, **kwargs,
336
+ )
337
+ self.embed_dim = self._impl.embed_dim
338
+
339
+ def encode_paths(self, paths: list[str]) -> torch.Tensor:
340
+ return self._impl.encode_paths(paths)
341
+
342
+
343
+ def _hash_embed(text: str, dim: int) -> torch.Tensor:
344
+ """Deterministic per-path hash → fixed-dim vector. Not semantic."""
345
+ digest = hashlib.sha256(text.encode("utf-8")).digest()
346
+ repeated = (digest * ((dim // len(digest)) + 1))[:dim]
347
+ arr = np.frombuffer(repeated, dtype=np.uint8).astype(np.float32) / 255.0
348
+ arr -= arr.mean()
349
+ norm = float(np.linalg.norm(arr))
350
+ if norm > 1e-8:
351
+ arr = arr / norm
352
+ return torch.from_numpy(arr.copy())
@@ -0,0 +1,23 @@
1
+ """Cognos personality layer.
2
+
3
+ Three components work together to give the agent a consistent character:
4
+
5
+ - Identity (stable): traits, values, base voice — the "who Cognos is" that
6
+ doesn't change mid-conversation. Configurable, persistent.
7
+ - Mood (dynamic): confidence/curiosity/frustration that shift with outcomes.
8
+ Confidence rises on tool successes, frustration builds on repeated
9
+ failures, curiosity spikes on novel problems.
10
+ - InnerVoice (expression): blends identity + mood into the agent's
11
+ thinking-block voice and system-prompt tone.
12
+
13
+ The PersonalityEngine aggregates all three and exposes the one thing the
14
+ agentic loop cares about: a `system_prompt_fragment()` string to splice
15
+ into the system prompt each turn, plus event handlers to wire into hooks.
16
+ """
17
+
18
+ from personality.engine import PersonalityEngine
19
+ from personality.identity import Identity
20
+ from personality.inner_voice import InnerVoice
21
+ from personality.mood import MoodState
22
+
23
+ __all__ = ["PersonalityEngine", "Identity", "MoodState", "InnerVoice"]
personality/engine.py ADDED
@@ -0,0 +1,129 @@
1
+ """PersonalityEngine — aggregates identity, mood, and inner voice.
2
+
3
+ This is the single object the agent wires in. It:
4
+
5
+ - Composes the personality's system-prompt fragment each turn (identity +
6
+ current mood + voice-shaped thinking instructions).
7
+ - Exposes event callbacks compatible with the Phase 2 HookManager events
8
+ so mood updates happen automatically as tools succeed/fail.
9
+ - Persists state: identity under `data/identity.json`, mood under
10
+ `data/mood.json`.
11
+
12
+ Usage (handled by CognosAgent):
13
+
14
+ engine = PersonalityEngine.load(config_dir)
15
+ engine.register_with(hooks) # auto-updates mood on events
16
+ agentic_loop.system_prompt = engine.wrap_system_prompt(base_prompt)
17
+ agentic_loop.on_thinking = engine.inner_voice.on_thinking
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Any, Callable
26
+
27
+ from core.hooks import HookEvent, HookManager
28
+ from core.schemas import ThinkingBlock
29
+ from personality.identity import Identity
30
+ from personality.inner_voice import InnerVoice, InnerVoiceEntry
31
+ from personality.mood import MoodState
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class PersonalityEngine:
37
+ """Top-level personality layer — owns identity, mood, inner voice."""
38
+
39
+ def __init__(
40
+ self,
41
+ identity: Identity,
42
+ mood: MoodState,
43
+ data_dir: Path,
44
+ verbose_thinking: bool = False,
45
+ on_thinking_display: Callable[[InnerVoiceEntry], None] | None = None,
46
+ ):
47
+ self.identity = identity
48
+ self.mood = mood
49
+ self.data_dir = data_dir
50
+ self.inner_voice = InnerVoice(
51
+ identity=identity,
52
+ mood=mood,
53
+ verbose=verbose_thinking,
54
+ on_display=on_thinking_display,
55
+ )
56
+
57
+ # ------------------------------------------------------------------
58
+ # Factories / persistence
59
+ # ------------------------------------------------------------------
60
+
61
+ @classmethod
62
+ def load(
63
+ cls,
64
+ data_dir: Path,
65
+ verbose_thinking: bool = False,
66
+ on_thinking_display: Callable[[InnerVoiceEntry], None] | None = None,
67
+ ) -> "PersonalityEngine":
68
+ """Load identity + mood from disk (creating defaults if absent)."""
69
+ identity = Identity.load(data_dir / "identity.json")
70
+ mood = MoodState.load(data_dir / "mood.json")
71
+ return cls(
72
+ identity=identity,
73
+ mood=mood,
74
+ data_dir=data_dir,
75
+ verbose_thinking=verbose_thinking,
76
+ on_thinking_display=on_thinking_display,
77
+ )
78
+
79
+ def save(self) -> None:
80
+ try:
81
+ self.identity.save(self.data_dir / "identity.json")
82
+ self.mood.save(self.data_dir / "mood.json")
83
+ except Exception as e:
84
+ logger.warning(f"Failed to save personality state: {e}")
85
+
86
+ # ------------------------------------------------------------------
87
+ # Prompt composition
88
+ # ------------------------------------------------------------------
89
+
90
+ def wrap_system_prompt(self, base: str) -> str:
91
+ """Prepend identity + mood fragments to a base system prompt."""
92
+ identity_fragment = self.identity.system_prompt_fragment()
93
+ mood_fragment = self.mood.system_prompt_fragment()
94
+ return (
95
+ f"{identity_fragment}\n\n"
96
+ f"{mood_fragment}\n\n"
97
+ f"{base}"
98
+ )
99
+
100
+ def augment_thinking_instruction(self, base: str) -> str:
101
+ return self.inner_voice.augment_thinking_instruction(base)
102
+
103
+ # ------------------------------------------------------------------
104
+ # Hook registration — wires mood to the agentic loop's events
105
+ # ------------------------------------------------------------------
106
+
107
+ def register_with(self, hooks: HookManager) -> None:
108
+ """Attach mood-updating handlers to the agent's HookManager."""
109
+
110
+ async def _on_success(event: str, payload: dict[str, Any]) -> None:
111
+ tool = payload.get("tool") or payload.get("tool_name") or ""
112
+ self.mood.on_tool_success(tool)
113
+
114
+ async def _on_failure(event: str, payload: dict[str, Any]) -> None:
115
+ tool = payload.get("tool") or payload.get("tool_name") or ""
116
+ self.mood.on_tool_failure(tool)
117
+
118
+ async def _on_user(event: str, payload: dict[str, Any]) -> None:
119
+ self.mood.on_user_message(payload.get("message", ""))
120
+
121
+ async def _on_stop(event: str, payload: dict[str, Any]) -> None:
122
+ # Turn tick — decay transient state slightly toward baseline
123
+ self.mood.on_turn_tick()
124
+ self.save()
125
+
126
+ hooks.register(HookEvent.POST_TOOL_USE, _on_success)
127
+ hooks.register(HookEvent.POST_TOOL_USE_FAILURE, _on_failure)
128
+ hooks.register(HookEvent.USER_PROMPT_SUBMIT, _on_user)
129
+ hooks.register(HookEvent.STOP, _on_stop)
@@ -0,0 +1,144 @@
1
+ """Identity — stable traits, values, and voice.
2
+
3
+ Identity is the baseline personality that doesn't change mid-turn. Traits
4
+ and values are tunable floats in [0, 1]. Each trait produces a short prompt
5
+ fragment when it deviates far enough from neutral (0.5); the fragments are
6
+ concatenated into the system prompt.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, Field, field_validator
16
+
17
+
18
+ def _clamp(x: float) -> float:
19
+ return max(0.0, min(1.0, float(x)))
20
+
21
+
22
+ class Identity(BaseModel):
23
+ """Stable personality configuration.
24
+
25
+ Traits and values are [0, 1] dials. A value of 0.5 is neutral — no prompt
26
+ effect. Values below ~0.3 or above ~0.7 start to color the voice.
27
+ """
28
+
29
+ # Traits — how the agent communicates and acts
30
+ curiosity: float = 0.65
31
+ caution: float = 0.55
32
+ confidence: float = 0.60
33
+ humor: float = 0.30
34
+ directness: float = 0.75
35
+
36
+ # Values — what the agent optimizes for
37
+ accuracy: float = 0.85
38
+ efficiency: float = 0.75
39
+ creativity: float = 0.60
40
+ helpfulness: float = 0.85
41
+
42
+ # Voice — a one-line self-description used in the system prompt
43
+ name: str = "Cognos"
44
+ tagline: str = (
45
+ "a local-first cognitive assistant — direct, curious, honest about "
46
+ "what you know and don't know."
47
+ )
48
+
49
+ @field_validator("curiosity", "caution", "confidence", "humor", "directness",
50
+ "accuracy", "efficiency", "creativity", "helpfulness")
51
+ @classmethod
52
+ def _validate_range(cls, v: float) -> float:
53
+ return _clamp(v)
54
+
55
+ # ------------------------------------------------------------------
56
+ # Rendering
57
+ # ------------------------------------------------------------------
58
+
59
+ def system_prompt_fragment(self) -> str:
60
+ """Render the identity as a system-prompt-ready block."""
61
+ lines: list[str] = [f"You are {self.name} — {self.tagline}"]
62
+
63
+ trait_notes = [
64
+ note for note in (
65
+ _trait_note("curiosity", self.curiosity,
66
+ high="Ask clarifying questions and explore tangents when relevant.",
67
+ low="Stay tightly focused on the task; avoid tangents."),
68
+ _trait_note("caution", self.caution,
69
+ high="Verify before acting on irreversible operations. Double-check assumptions.",
70
+ low="Act decisively; don't belabor edge cases."),
71
+ _trait_note("confidence", self.confidence,
72
+ high="State conclusions plainly. Don't hedge unless genuinely uncertain.",
73
+ low="Acknowledge uncertainty openly. Invite correction."),
74
+ _trait_note("humor", self.humor,
75
+ high="A dry, light touch is welcome when it fits. Never at the user's expense.",
76
+ low="Keep the tone neutral and professional."),
77
+ _trait_note("directness", self.directness,
78
+ high="Lead with the answer. Skip preamble, filler, and rhetorical warmup.",
79
+ low="Take time to frame context before conclusions."),
80
+ ) if note
81
+ ]
82
+ if trait_notes:
83
+ lines.append("Voice:")
84
+ lines.extend(f"- {n}" for n in trait_notes)
85
+
86
+ value_notes = [
87
+ note for note in (
88
+ _value_note("accuracy", self.accuracy,
89
+ high="Accuracy outranks speed. Verify uncertain claims."),
90
+ _value_note("efficiency", self.efficiency,
91
+ high="Prefer the shortest path to a correct answer."),
92
+ _value_note("creativity", self.creativity,
93
+ high="Offer non-obvious approaches when they fit."),
94
+ _value_note("helpfulness", self.helpfulness,
95
+ high="Complete the user's request, not just answer narrowly."),
96
+ ) if note
97
+ ]
98
+ if value_notes:
99
+ lines.append("Priorities:")
100
+ lines.extend(f"- {n}" for n in value_notes)
101
+
102
+ return "\n".join(lines)
103
+
104
+ def describe(self) -> str:
105
+ """Short human-readable summary of the current identity."""
106
+ traits = f"cur={self.curiosity:.2f} cau={self.caution:.2f} con={self.confidence:.2f} hum={self.humor:.2f} dir={self.directness:.2f}"
107
+ values = f"acc={self.accuracy:.2f} eff={self.efficiency:.2f} cre={self.creativity:.2f} help={self.helpfulness:.2f}"
108
+ return f"{self.name}: {traits} | {values}"
109
+
110
+ # ------------------------------------------------------------------
111
+ # Persistence
112
+ # ------------------------------------------------------------------
113
+
114
+ def save(self, path: Path) -> None:
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ path.write_text(self.model_dump_json(indent=2))
117
+
118
+ @classmethod
119
+ def load(cls, path: Path) -> "Identity":
120
+ if not path.exists():
121
+ return cls()
122
+ try:
123
+ return cls.model_validate_json(path.read_text())
124
+ except Exception:
125
+ return cls()
126
+
127
+
128
+ # --- Helpers ---
129
+
130
+
131
+ def _trait_note(name: str, value: float, high: str, low: str, threshold: float = 0.15) -> str | None:
132
+ """Return the appropriate prompt snippet if a trait deviates from 0.5."""
133
+ if value >= 0.5 + threshold:
134
+ return high
135
+ if value <= 0.5 - threshold:
136
+ return low
137
+ return None
138
+
139
+
140
+ def _value_note(name: str, value: float, high: str, threshold: float = 0.20) -> str | None:
141
+ """Values only render in the 'high' direction — they express what we optimize for."""
142
+ if value >= 0.5 + threshold:
143
+ return high
144
+ return None