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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- 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())
|
personality/__init__.py
ADDED
|
@@ -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)
|
personality/identity.py
ADDED
|
@@ -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
|