behavior-os 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.
@@ -0,0 +1,5 @@
1
+ from agentic_mindset.registry import CharacterRegistry
2
+ from agentic_mindset.fusion import FusionEngine
3
+ from agentic_mindset.context import ContextBlock
4
+
5
+ __all__ = ["CharacterRegistry", "FusionEngine", "ContextBlock"]
agentic_mindset/cli.py ADDED
@@ -0,0 +1,504 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ import typer
10
+ import yaml
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+
14
+ from agentic_mindset.pack import CharacterPack, PackLoadError
15
+ from agentic_mindset.registry import CharacterRegistry
16
+ from agentic_mindset.fusion import FusionEngine, FusionStrategy, FusionReport
17
+ from agentic_mindset.context import ContextBlock
18
+ from agentic_mindset.resolver.resolver import ConflictResolver
19
+ from agentic_mindset.renderer.inject import render_for_runtime as _render_inject
20
+
21
+ app = typer.Typer(name="mindset", help="Agentic Mindset CLI")
22
+ console = Console()
23
+
24
+ _GENERATE_SCHEMA_VERSION = "1.0"
25
+
26
+
27
+ def _format_output(text: str, fmt: str, meta: dict | None = None) -> str:
28
+ if fmt == "text":
29
+ return text
30
+ if fmt == "anthropic-json":
31
+ return json.dumps({"type": "text", "text": text})
32
+ if fmt == "debug-json":
33
+ return json.dumps({"meta": meta, "type": "text", "text": text}, indent=2)
34
+ raise ValueError(f"Unknown output format: {fmt!r}")
35
+
36
+
37
+ def _explain_decision_policy(report: "FusionReport") -> str:
38
+ """Build the decision_policy string for --explain YAML output."""
39
+ if len(report.personas) == 1:
40
+ return f"{report.personas[0][0]}-only"
41
+ if report.dominant_character is not None:
42
+ return f"{report.dominant_character}-dominant"
43
+ return "equal-blend"
44
+
45
+
46
+ _TEMPLATE_META = {
47
+ "id": "{id}",
48
+ "name": "{name}",
49
+ "version": "1.0.0",
50
+ "schema_version": "1.0",
51
+ "type": "{type}",
52
+ "description": "TODO: describe this character",
53
+ "tags": [],
54
+ "authors": [{"name": "TODO", "url": ""}],
55
+ "created": "{today}",
56
+ }
57
+ _TEMPLATE_MINDSET = {
58
+ "core_principles": [{"description": "TODO", "detail": "TODO"}],
59
+ "decision_framework": {"risk_tolerance": "medium", "time_horizon": "long-term", "approach": "TODO"},
60
+ "thinking_patterns": ["TODO"],
61
+ "mental_models": [{"name": "TODO", "description": "TODO"}],
62
+ }
63
+ _TEMPLATE_PERSONALITY = {
64
+ "traits": [{"name": "TODO", "description": "TODO", "intensity": 0.5}],
65
+ "emotional_tendencies": {"stress_response": "TODO", "motivation_source": "TODO"},
66
+ "interpersonal_style": {"communication": "TODO", "leadership": "TODO"},
67
+ "drives": ["TODO"],
68
+ }
69
+ _TEMPLATE_BEHAVIOR = {
70
+ "work_patterns": ["TODO"],
71
+ "decision_speed": "deliberate",
72
+ "execution_style": ["TODO"],
73
+ "conflict_style": "TODO",
74
+ }
75
+ _TEMPLATE_VOICE = {
76
+ "tone": "TODO",
77
+ "vocabulary": {"preferred": [], "avoided": []},
78
+ "sentence_style": "TODO",
79
+ "signature_phrases": [],
80
+ }
81
+ _TEMPLATE_SOURCES = {
82
+ "sources": [
83
+ {"title": "TODO source 1", "type": "book", "accessed": "2026-01-01"},
84
+ {"title": "TODO source 2", "type": "book", "accessed": "2026-01-01"},
85
+ {"title": "TODO source 3", "type": "book", "accessed": "2026-01-01"},
86
+ ]
87
+ }
88
+
89
+
90
+ @app.command()
91
+ def init(
92
+ character_id: str = typer.Argument(..., help="Character ID (kebab-case)"),
93
+ type_: str = typer.Option("historical", "--type", help="historical or fictional"),
94
+ output: Optional[Path] = typer.Option(None, "--output", help="Directory to create pack in"),
95
+ ):
96
+ """Scaffold a new empty character pack."""
97
+ from datetime import date
98
+ out_dir = (output or Path(".")) / character_id
99
+ if type_ not in ("historical", "fictional"):
100
+ console.print(f"[red]--type must be 'historical' or 'fictional', got: {type_!r}[/red]")
101
+ raise typer.Exit(1)
102
+ if out_dir.exists():
103
+ console.print(f"[red]Directory already exists: {out_dir}[/red]")
104
+ raise typer.Exit(1)
105
+ out_dir.mkdir(parents=True)
106
+
107
+ name = character_id.replace("-", " ").title()
108
+ today = date.today().isoformat()
109
+
110
+ def _render(template: dict) -> dict:
111
+ s = json.dumps(template)
112
+ s = s.replace("{id}", character_id).replace("{name}", name)
113
+ s = s.replace("{type}", type_).replace("{today}", today)
114
+ return json.loads(s)
115
+
116
+ files = {
117
+ "meta.yaml": _render(_TEMPLATE_META),
118
+ "mindset.yaml": _TEMPLATE_MINDSET,
119
+ "personality.yaml": _TEMPLATE_PERSONALITY,
120
+ "behavior.yaml": _TEMPLATE_BEHAVIOR,
121
+ "voice.yaml": _TEMPLATE_VOICE,
122
+ "sources.yaml": _TEMPLATE_SOURCES,
123
+ }
124
+ for fname, data in files.items():
125
+ (out_dir / fname).write_text(yaml.dump(data, allow_unicode=True))
126
+
127
+ console.print(f"[green]Created character pack:[/green] {out_dir}")
128
+
129
+
130
+ @app.command()
131
+ def validate(
132
+ pack_path: Path = typer.Argument(..., help="Path to character pack directory"),
133
+ ):
134
+ """Validate a character pack against the schema."""
135
+ try:
136
+ CharacterPack.load(pack_path)
137
+ console.print(f"[green]✓ Pack is valid:[/green] {pack_path}")
138
+ except PackLoadError as e:
139
+ console.print(f"[red]✗ Validation failed:[/red]\n{e}")
140
+ raise typer.Exit(1)
141
+
142
+
143
+ @app.command()
144
+ def preview(
145
+ pack_path: Optional[Path] = typer.Argument(None, help="Path to a single character pack"),
146
+ fusion_config: Optional[Path] = typer.Option(None, "--fusion", help="Path to fusion.yaml"),
147
+ output_format: str = typer.Option("plain_text", "--format", help="plain_text or xml_tagged"),
148
+ registry_path: Optional[Path] = typer.Option(None, "--registry", help="Override registry path"),
149
+ ):
150
+ """Preview the Context Block for a character or fusion."""
151
+ if pack_path is None and fusion_config is None:
152
+ console.print("[red]Provide either a pack path or --fusion config.[/red]")
153
+ raise typer.Exit(1)
154
+
155
+ if pack_path:
156
+ pack = CharacterPack.load(pack_path)
157
+ block = ContextBlock.from_packs([(pack, 1.0)])
158
+ else:
159
+ cfg = yaml.safe_load(fusion_config.read_text(encoding="utf-8"))
160
+ search_paths = [registry_path] if registry_path else None
161
+ registry = CharacterRegistry(search_paths=search_paths)
162
+ engine = FusionEngine(registry)
163
+ chars = [(c["id"], c["weight"]) for c in cfg["characters"]]
164
+ strategy = FusionStrategy(cfg.get("fusion_strategy", "blend"))
165
+ block = engine.fuse(chars, strategy=strategy)
166
+
167
+ console.print(Panel(block.to_prompt(output_format=output_format), title="Context Block"))
168
+
169
+
170
+ def _parse_weights(weights_str: Optional[str], ids: list[str]) -> Optional[list[float]]:
171
+ """Parse --weights string into a list of floats. Prints errors to stderr and returns None on failure."""
172
+ if weights_str is None:
173
+ return [1.0] * len(ids)
174
+
175
+ # reject trailing/leading commas or empty segments
176
+ if weights_str.startswith(",") or weights_str.endswith(",") or ",," in weights_str:
177
+ typer.echo(
178
+ "Error: --weights must be comma-separated numbers (e.g. --weights 6,4).",
179
+ err=True,
180
+ )
181
+ return None
182
+
183
+ parts = weights_str.split(",")
184
+
185
+ # count must match number of original IDs
186
+ if len(parts) != len(ids):
187
+ typer.echo(
188
+ f"Error: --weights has {len(parts)} values but {len(ids)} character IDs were given.",
189
+ err=True,
190
+ )
191
+ return None
192
+
193
+ parsed: list[float] = []
194
+ for part in parts:
195
+ try:
196
+ val = float(part)
197
+ except ValueError:
198
+ typer.echo(
199
+ "Error: --weights must be comma-separated numbers (e.g. --weights 6,4).",
200
+ err=True,
201
+ )
202
+ return None
203
+ if val < 0:
204
+ typer.echo("Error: --weights values must be positive numbers.", err=True)
205
+ return None
206
+ parsed.append(val)
207
+
208
+ if all(w == 0.0 for w in parsed):
209
+ typer.echo("Error: --weights values cannot all be zero.", err=True)
210
+ return None
211
+
212
+ return parsed
213
+
214
+
215
+ def _deduplicate(ids: list[str], weights: list[float]) -> tuple[list[str], list[float]]:
216
+ """Merge duplicate IDs by summing their weights, then normalize to sum=1.0."""
217
+ merged: dict[str, float] = {}
218
+ for cid, w in zip(ids, weights):
219
+ merged[cid] = merged.get(cid, 0.0) + w
220
+
221
+ total = sum(merged.values())
222
+ ids_out = list(merged.keys())
223
+ weights_out = [merged[cid] / total for cid in ids_out]
224
+ return ids_out, weights_out
225
+
226
+
227
+ @app.command("list")
228
+ def list_characters(
229
+ registry: Optional[Path] = typer.Option(None, "--registry", help="Override registry path"),
230
+ ):
231
+ """List available characters in the registry."""
232
+ search_paths = [registry] if registry else None
233
+ reg = CharacterRegistry(search_paths=search_paths)
234
+ ids = reg.list_ids()
235
+ if not ids:
236
+ console.print("[yellow]No characters found.[/yellow]")
237
+ for cid in ids:
238
+ console.print(f" {cid}")
239
+
240
+
241
+ @app.command()
242
+ def generate(
243
+ ids: list[str] = typer.Argument(..., help="Character IDs to compile"),
244
+ weights: Optional[str] = typer.Option(None, "--weights", help="Comma-separated weights"),
245
+ strategy: str = typer.Option("blend", "--strategy", help="blend | dominant"),
246
+ format_: str = typer.Option("text", "--format", help="text | anthropic-json | debug-json"),
247
+ output: Optional[Path] = typer.Option(None, "--output", help="Write to file instead of stdout"),
248
+ explain: bool = typer.Option(False, "--explain", help="Print compilation summary to stderr"),
249
+ registry: Optional[Path] = typer.Option(None, "--registry", help="Override registry path"),
250
+ ):
251
+ """Compile character mindset(s) into an injectable system prompt block."""
252
+ search_paths = [registry] if registry else None
253
+ reg = CharacterRegistry(search_paths=search_paths)
254
+
255
+ # --- parse and validate weights ---
256
+ parsed_weights = _parse_weights(weights, ids)
257
+ if parsed_weights is None:
258
+ raise typer.Exit(1)
259
+
260
+ # --- deduplicate IDs, summing weights ---
261
+ ids_deduped, weights_deduped = _deduplicate(ids, parsed_weights)
262
+
263
+ # --- load characters (validate existence) ---
264
+ # Note: reg.load_id() is called here for early validation with a clear error message.
265
+ # FusionEngine.fuse() takes (id, weight) pairs and re-loads from registry internally.
266
+ # This double-load is intentional: the validation pass gives a targeted error before
267
+ # any fusion work begins.
268
+ missing_cid = None
269
+ for cid in ids_deduped:
270
+ try:
271
+ reg.load_id(cid)
272
+ except KeyError:
273
+ missing_cid = cid
274
+ break
275
+ if missing_cid is not None:
276
+ typer.echo(
277
+ f"Error: character '{missing_cid}' not found. Run 'mindset list' to see available characters.",
278
+ err=True,
279
+ )
280
+ raise typer.Exit(1)
281
+
282
+ # --- fuse ---
283
+ engine = FusionEngine(reg)
284
+
285
+ if strategy == "sequential":
286
+ typer.echo("Error: --strategy sequential is not supported in v0.", err=True)
287
+ raise typer.Exit(1)
288
+
289
+ try:
290
+ strat = FusionStrategy(strategy)
291
+ except ValueError:
292
+ typer.echo(f"Error: unknown strategy '{strategy}'.", err=True)
293
+ raise typer.Exit(1)
294
+
295
+ chars = list(zip(ids_deduped, weights_deduped))
296
+ report = FusionReport() if explain else None
297
+ block = engine.fuse(chars, strategy=strat, report=report)
298
+ text = block.to_prompt(output_format="plain_text")
299
+
300
+ # --- format ---
301
+ meta = {
302
+ "characters": ids_deduped,
303
+ "weights": weights_deduped,
304
+ "strategy": strategy,
305
+ "schema_version": _GENERATE_SCHEMA_VERSION,
306
+ }
307
+ result_str = _format_output(text, format_, meta=meta)
308
+
309
+ # --- explain ---
310
+ if explain:
311
+ weighted_packs_ex = engine.prepare_packs(chars, strat)
312
+ dominant_pack = weighted_packs_ex[0][0]
313
+ explain_data = {
314
+ "personas": [{cid: round(w, 4)} for cid, w in report.personas],
315
+ "merged": {
316
+ "decision_policy": _explain_decision_policy(report),
317
+ "risk_tolerance": dominant_pack.mindset.decision_framework.risk_tolerance,
318
+ "time_horizon": dominant_pack.mindset.decision_framework.time_horizon,
319
+ },
320
+ "removed_conflicts": report.removed_items,
321
+ }
322
+ typer.echo(
323
+ yaml.dump(explain_data, default_flow_style=False, allow_unicode=True),
324
+ err=True,
325
+ )
326
+
327
+ # --- output ---
328
+ if output:
329
+ try:
330
+ output.write_text(result_str, encoding="utf-8")
331
+ except OSError as e:
332
+ typer.echo(f"Error: cannot write to '{output}': {e}.", err=True)
333
+ raise typer.Exit(1)
334
+ else:
335
+ typer.echo(result_str)
336
+
337
+
338
+ def _emit_explain_from_ir(ir: "BehaviorIR") -> None:
339
+ """Emit structured YAML explain output for the inject path."""
340
+ slots_data = {}
341
+ for slot_name, slot in ir.slots.items():
342
+ slots_data[slot_name] = {
343
+ "primary": {
344
+ "value": slot.primary.value,
345
+ "source": slot.primary.source,
346
+ "weight": round(slot.primary.weight, 4),
347
+ },
348
+ "has_conflict": slot.has_conflict,
349
+ "modifiers": [
350
+ {
351
+ "value": m.value,
352
+ "condition": m.condition,
353
+ "conjunction": m.conjunction,
354
+ "source": m.source,
355
+ "provenance": m.provenance,
356
+ **({"note": m.note} if m.note else {}),
357
+ }
358
+ for m in slot.modifiers
359
+ ],
360
+ "dropped": [
361
+ {
362
+ "value": d.value,
363
+ "source": d.source,
364
+ "weight": round(d.weight, 4),
365
+ "reason": d.reason,
366
+ }
367
+ for d in slot.dropped
368
+ ],
369
+ }
370
+ data = {
371
+ "personas": [{cid: round(w, 4)} for cid, w in ir.preamble.personas],
372
+ "slots": slots_data,
373
+ }
374
+ typer.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True), err=True)
375
+
376
+
377
+ def _emit_explain_from_report(
378
+ report: "FusionReport",
379
+ weighted_packs: list,
380
+ ) -> None:
381
+ """Emit explain YAML for the text path."""
382
+ dominant_pack = weighted_packs[0][0]
383
+ # Populate report.personas from weighted_packs if not already set
384
+ if not report.personas:
385
+ report.personas = [(pack.meta.id, weight) for pack, weight in weighted_packs]
386
+ explain_data = {
387
+ "personas": [{cid: round(w, 4)} for cid, w in report.personas],
388
+ "merged": {
389
+ "decision_policy": _explain_decision_policy(report),
390
+ "risk_tolerance": dominant_pack.mindset.decision_framework.risk_tolerance,
391
+ "time_horizon": dominant_pack.mindset.decision_framework.time_horizon,
392
+ },
393
+ "removed_conflicts": report.removed_items,
394
+ }
395
+ typer.echo(
396
+ yaml.dump(explain_data, default_flow_style=False, allow_unicode=True),
397
+ err=True,
398
+ )
399
+
400
+
401
+ @app.command()
402
+ def run(
403
+ runtime: str = typer.Argument(..., help="Runtime name (v0: claude only)"),
404
+ persona: list[str] = typer.Option(..., "--persona", help="Character ID. Repeat for multi-persona."),
405
+ weights: Optional[str] = typer.Option(None, "--weights", help="Comma-separated weights, auto-normalized"),
406
+ strategy: str = typer.Option("blend", "--strategy", help="blend | dominant"),
407
+ format_: str = typer.Option("inject", "--format", help="text | inject (v0: equivalent)"),
408
+ registry: Optional[Path] = typer.Option(None, "--registry", help="Override registry path"),
409
+ explain: bool = typer.Option(False, "--explain", help="Print compilation summary to stderr"),
410
+ query: Optional[str] = typer.Argument(None, help="One-shot query. Omit for interactive mode."),
411
+ ):
412
+ """Compile mindset(s) and inject into an agent runtime."""
413
+ # --- compile phase ---
414
+ search_paths = [registry] if registry else None
415
+ reg = CharacterRegistry(search_paths=search_paths)
416
+
417
+ parsed_weights = _parse_weights(weights, persona)
418
+ if parsed_weights is None:
419
+ raise typer.Exit(1)
420
+
421
+ ids_deduped, weights_deduped = _deduplicate(persona, parsed_weights)
422
+
423
+ missing_cid = None
424
+ for cid in ids_deduped:
425
+ try:
426
+ reg.load_id(cid)
427
+ except KeyError:
428
+ missing_cid = cid
429
+ break
430
+ if missing_cid is not None:
431
+ typer.echo(
432
+ f"Error: character '{missing_cid}' not found. Run 'mindset list' to see available characters.",
433
+ err=True,
434
+ )
435
+ raise typer.Exit(1)
436
+
437
+ if strategy == "sequential":
438
+ typer.echo("Error: --strategy sequential is not supported by 'run' (v0).", err=True)
439
+ raise typer.Exit(1)
440
+
441
+ try:
442
+ strat = FusionStrategy(strategy)
443
+ except ValueError:
444
+ typer.echo(f"Error: unknown strategy '{strategy}'.", err=True)
445
+ raise typer.Exit(1)
446
+
447
+ engine = FusionEngine(reg)
448
+ chars = list(zip(ids_deduped, weights_deduped))
449
+
450
+ # Both paths start from prepare_packs for identical normalization.
451
+ weighted_packs = engine.prepare_packs(chars, strat)
452
+
453
+ if format_ == "inject":
454
+ # New path: ConflictResolver → BehaviorIR → ClaudeRenderer
455
+ ir = ConflictResolver().resolve(weighted_packs)
456
+ injected = _render_inject(ir, fmt="inject")
457
+
458
+ if explain:
459
+ _emit_explain_from_ir(ir)
460
+
461
+ elif format_ == "text":
462
+ # Existing path: ContextBlock → to_prompt
463
+ show_weights = strat != FusionStrategy.sequential
464
+ report = FusionReport() if explain else None
465
+ block = ContextBlock.from_packs(weighted_packs, show_weights=show_weights, report=report)
466
+ injected = block.to_prompt("plain_text")
467
+
468
+ if explain:
469
+ _emit_explain_from_report(report, weighted_packs)
470
+
471
+ else:
472
+ typer.echo(f"Error: unknown format '{format_}'.", err=True)
473
+ raise typer.Exit(1)
474
+
475
+ # --- write temp file ---
476
+ fd, tmppath = tempfile.mkstemp(suffix=".txt", prefix="mindset_run_")
477
+ try:
478
+ try:
479
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
480
+ f.write(injected)
481
+ except OSError as e:
482
+ typer.echo(f"Error: failed to write temporary file: {e}.", err=True)
483
+ raise typer.Exit(1)
484
+
485
+ # --- runtime phase ---
486
+ if shutil.which(runtime) is None:
487
+ typer.echo(
488
+ f"Error: '{runtime}' not found. Install Claude CLI: https://claude.ai/code",
489
+ err=True,
490
+ )
491
+ raise typer.Exit(1)
492
+
493
+ cmd = [runtime, "--append-system-prompt-file", tmppath]
494
+ if query is not None:
495
+ cmd.append(query)
496
+
497
+ proc = subprocess.run(cmd, check=False)
498
+ raise typer.Exit(proc.returncode)
499
+
500
+ finally:
501
+ try:
502
+ os.unlink(tmppath)
503
+ except OSError:
504
+ pass # best-effort cleanup