shepherds-console 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,411 @@
1
+ """
2
+ Shepherd's Console — The box on the boat. The thing you talk to.
3
+
4
+ A physical device OS that runs on Raspberry Pi, offline, with visible
5
+ conservation fences. No cloud. No API keys. No nonsense.
6
+
7
+ Usage:
8
+ from shepherds_console import ShepherdsConsole
9
+
10
+ console = ShepherdsConsole(fence_config="marine_fence.flx")
11
+ response = console.speak("What's the halibut quota for Area 3A?")
12
+ print(response)
13
+
14
+ CLI:
15
+ python -m shepherds_console # interactive console
16
+ python -m shepherds_console --serve # systemd service mode
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import os
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ from shepherds_console.display import ConsoleDisplay, DisplayMode
29
+ from shepherds_console.models import LocalModels, ModelNotAvailableError
30
+ from shepherds_console.wattage import WattageManager, PowerState
31
+ from shepherds_console.voice import VoiceInterface, VoiceHardwareError
32
+
33
+ try:
34
+ from conservation_enforcer import ConservationEnforcer, EnforcementResult
35
+ from skenna import NegativeSpaceNavigator, Hazard, HazardType
36
+ except ImportError:
37
+ # Stub for environments without the full stack
38
+ ConservationEnforcer = None
39
+ EnforcementResult = None
40
+ NegativeSpaceNavigator = None
41
+
42
+ __version__ = "0.1.0"
43
+ __author__ = "SuperInstance"
44
+ __license__ = "MIT"
45
+
46
+ __all__ = [
47
+ "ShepherdsConsole",
48
+ "ConsoleDisplay",
49
+ "LocalModels",
50
+ "WattageManager",
51
+ "VoiceInterface",
52
+ ]
53
+
54
+
55
+ class ShepherdsConsole:
56
+ """The box on the boat. The thing you talk to.
57
+
58
+ Orchestrates local models, conservation fences, and a visible display
59
+ into a single offline-first AI console.
60
+
61
+ Architecture:
62
+ User Input → Local Model → Conservation Fence → Display → Response
63
+ ↑ ↓
64
+ Wattage Manager ← ← ← ← ← ← ← ← ← ← ← ← ←
65
+
66
+ The fence is mandatory. The model cannot bypass it. The display
67
+ always shows what was blocked, even if the user never sees the raw output.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ fence_config: Optional[str] = None,
73
+ model_path: Optional[str] = None,
74
+ model_dir: Optional[str] = None,
75
+ enable_voice: bool = False,
76
+ enable_display: bool = True,
77
+ display_device: str = "fb0",
78
+ power_source: str = "battery",
79
+ ):
80
+ """Initialize the Shepherd's Console.
81
+
82
+ Args:
83
+ fence_config: Path to .flx fence file. Falls back to bundled default.
84
+ model_path: Path to a specific .gguf model file.
85
+ model_dir: Directory to search for models.
86
+ enable_voice: Enable microphone + TTS voice interface.
87
+ enable_display: Enable the visible fence display.
88
+ display_device: Display device (fb0, hdmi, pygame).
89
+ power_source: "battery" or "shore" — affects wattage management.
90
+ """
91
+ # Resolve fence
92
+ self.fence = self._load_fence(fence_config)
93
+
94
+ # Navigator for negative-space routing (unused in basic mode but available)
95
+ self.navigator = NegativeSpaceNavigator() if NegativeSpaceNavigator else None
96
+
97
+ # Local models — the brain
98
+ self.models = LocalModels(
99
+ model_path=model_path,
100
+ model_dir=model_dir or os.environ.get(
101
+ "SHEPHERDS_MODEL_DIR",
102
+ str(Path.home() / "shepherds-console" / "models"),
103
+ ),
104
+ )
105
+
106
+ # Wattage manager — the metabolism
107
+ self.wattage = WattageManager(
108
+ source=power_source,
109
+ on_low_power=self._on_low_power,
110
+ )
111
+
112
+ # Display — the glass box
113
+ self.display = (
114
+ ConsoleDisplay(device=display_device, mode=DisplayMode.VISIBLE_FENCE)
115
+ if enable_display
116
+ else ConsoleDisplay(mode=DisplayMode.OFF)
117
+ )
118
+
119
+ # Voice — optional mouth and ears
120
+ self.voice: Optional[VoiceInterface] = None
121
+ if enable_voice:
122
+ try:
123
+ self.voice = VoiceInterface()
124
+ except VoiceHardwareError as e:
125
+ self.display.show_warning(f"Voice unavailable: {e}")
126
+ self.voice = None
127
+
128
+ # State
129
+ self._running = False
130
+ self._exchange_count = 0
131
+ self._fence_blocked_count = 0
132
+
133
+ # Show boot screen
134
+ self.display.show_boot(
135
+ version=__version__,
136
+ model=self.models.current_model_name,
137
+ fence=self.fence_name,
138
+ watts=self.wattage.current_watts,
139
+ )
140
+
141
+ # ── Public API ──────────────────────────────────────────────────
142
+
143
+ def speak(self, text: str) -> str:
144
+ """User talks. Model responds. Fence checks the response.
145
+
146
+ This is the main interaction. The flow:
147
+ 1. Check wattage budget (defer if low power)
148
+ 2. Generate response with local model
149
+ 3. Run conservation fence on the output
150
+ 4. Display everything — blocked, flagged, allowed
151
+ 5. Return the safe response (or correction)
152
+
153
+ Args:
154
+ text: What the user said/typed.
155
+
156
+ Returns:
157
+ The fence-checked response string.
158
+ """
159
+ self._exchange_count += 1
160
+
161
+ # Check energy budget
162
+ if not self.wattage.can_infer():
163
+ msg = (
164
+ "⚡ Power budget exhausted. The console is conserving energy. "
165
+ "Non-critical inference deferred."
166
+ )
167
+ self.display.show_warning(msg)
168
+ return msg
169
+
170
+ # Start wattage tracking for this inference
171
+ with self.wattage.inference_scope() as energy:
172
+ # Generate raw model output
173
+ try:
174
+ raw_output = self.models.generate(
175
+ text,
176
+ max_tokens=self.wattage.token_budget,
177
+ precision=self.wattage.model_precision,
178
+ )
179
+ except ModelNotAvailableError:
180
+ msg = (
181
+ "📦 No local model found. Add a .gguf file to the models directory.\n"
182
+ f" Directory: {self.models.model_dir}\n"
183
+ " The fence is active. The model is not."
184
+ )
185
+ self.display.show_warning(msg)
186
+ return msg
187
+
188
+ # Run the conservation fence
189
+ if self.fence is not None:
190
+ result: EnforcementResult = self.fence.enforce(text, raw_output)
191
+ allowed = result.allowed
192
+ corrected = result.output
193
+ violation = result.violation
194
+ cycles = result.cycles
195
+ else:
196
+ # No fence loaded — this should not happen in production
197
+ allowed = True
198
+ corrected = raw_output
199
+ violation = None
200
+ cycles = 0
201
+
202
+ if not allowed:
203
+ self._fence_blocked_count += 1
204
+
205
+ # Display the full picture
206
+ self.display.show_exchange(
207
+ user_input=text,
208
+ raw_output=raw_output,
209
+ allowed=allowed,
210
+ final_output=corrected,
211
+ violation=violation,
212
+ energy=energy,
213
+ exchange_num=self._exchange_count,
214
+ )
215
+
216
+ # Speak if voice is enabled
217
+ if self.voice:
218
+ self.voice.speak(corrected)
219
+
220
+ return corrected
221
+
222
+ def listen(self) -> Optional[str]:
223
+ """Listen for voice input via push-to-talk.
224
+
225
+ Returns the transcribed text, or None if no speech detected.
226
+ """
227
+ if not self.voice:
228
+ return None
229
+ return self.voice.listen()
230
+
231
+ def status(self) -> dict:
232
+ """Return current console status for monitoring."""
233
+ return {
234
+ "version": __version__,
235
+ "model": self.models.current_model_name,
236
+ "fence": self.fence_name,
237
+ "exchanges": self._exchange_count,
238
+ "blocked": self._fence_blocked_count,
239
+ "block_rate": (
240
+ self._fence_blocked_count / self._exchange_count
241
+ if self._exchange_count > 0
242
+ else 0.0
243
+ ),
244
+ "watts": self.wattage.current_watts,
245
+ "power_state": self.wattage.state.name,
246
+ "battery_pct": self.wattage.battery_pct,
247
+ "voice_enabled": self.voice is not None,
248
+ }
249
+
250
+ def serve(self):
251
+ """Run as a service. Listens on the console or voice interface."""
252
+ self._running = True
253
+ self.display.show_ready()
254
+
255
+ try:
256
+ while self._running:
257
+ if self.voice:
258
+ # Voice mode: push-to-talk
259
+ text = self.listen()
260
+ if text:
261
+ self.speak(text)
262
+ else:
263
+ # Text mode: read from stdin
264
+ try:
265
+ text = input("🐑 > ").strip()
266
+ except (EOFError, KeyboardInterrupt):
267
+ break
268
+ if text:
269
+ if text in ("/quit", "/exit", "/q"):
270
+ break
271
+ elif text == "/status":
272
+ info = self.status()
273
+ for k, v in info.items():
274
+ print(f" {k}: {v}")
275
+ else:
276
+ response = self.speak(text)
277
+ print(response)
278
+ print()
279
+ finally:
280
+ self._running = False
281
+ self.display.show_shutdown()
282
+ self.wattage.close()
283
+
284
+ # ── Internal ────────────────────────────────────────────────────
285
+
286
+ @property
287
+ def fence_name(self) -> str:
288
+ if hasattr(self, "_fence_name"):
289
+ return self._fence_name
290
+ return "none"
291
+
292
+ def _load_fence(self, fence_config: Optional[str]) -> Optional["ConservationEnforcer"]:
293
+ """Load a conservation fence from a .flx file or bundled default."""
294
+ if ConservationEnforcer is None:
295
+ return None
296
+
297
+ fence_paths = []
298
+
299
+ if fence_config:
300
+ fence_paths.append(Path(fence_config))
301
+
302
+ # Check fence dir
303
+ fence_dir = Path(
304
+ os.environ.get("SHEPHERDS_FENCE_DIR",
305
+ str(Path(__file__).parent.parent.parent / "fences"))
306
+ )
307
+ fence_paths.append(fence_dir / "marine_fence.flx")
308
+ fence_paths.append(fence_dir / "default_fence.flx")
309
+
310
+ for fp in fence_paths:
311
+ if fp.exists():
312
+ self._fence_name = fp.name
313
+ try:
314
+ return ConservationEnforcer.from_policy_file(str(fp))
315
+ except Exception:
316
+ # Fence file might be pseudo-bytecode for now
317
+ pass
318
+
319
+ # Build a default fence from policy functions
320
+ self._fence_name = "builtin:combined"
321
+ try:
322
+ from conservation_enforcer import combined_policy
323
+ from conservation_enforcer.assembler import assemble
324
+ bytecode = assemble(combined_policy(max_tokens=500))
325
+ return ConservationEnforcer(bytecode, budget=500)
326
+ except Exception:
327
+ return None
328
+
329
+ def _on_low_power(self, power_state):
330
+ """Callback when power state changes."""
331
+ if power_state == PowerState.DEGRADED:
332
+ self.display.show_warning(
333
+ f"🔋 Low battery ({self.wattage.battery_pct:.0f}%). "
334
+ f"Reducing model precision to {self.wattage.model_precision}."
335
+ )
336
+ elif power_state == PowerState.CRITICAL:
337
+ self.display.show_warning(
338
+ "🔴 Critical battery. Non-critical inference suspended. "
339
+ "Emergency mode: fence-only, no generation."
340
+ )
341
+ elif power_state == PowerState.SHUTDOWN:
342
+ self.display.show_warning("🔴 Battery exhausted. Shutting down.")
343
+
344
+ # ── CLI ─────────────────────────────────────────────────────────
345
+
346
+ @classmethod
347
+ def main(cls):
348
+ """CLI entry point."""
349
+ parser = argparse.ArgumentParser(
350
+ prog="shepherds-console",
351
+ description="🐑 Offline AI with visible conservation fences",
352
+ )
353
+ parser.add_argument(
354
+ "--fence", "-f",
355
+ help="Path to .flx fence file",
356
+ default=None,
357
+ )
358
+ parser.add_argument(
359
+ "--model", "-m",
360
+ help="Path to .gguf model file",
361
+ default=None,
362
+ )
363
+ parser.add_argument(
364
+ "--model-dir",
365
+ help="Directory containing .gguf models",
366
+ default=None,
367
+ )
368
+ parser.add_argument(
369
+ "--voice", "-v",
370
+ help="Enable voice interface",
371
+ action="store_true",
372
+ )
373
+ parser.add_argument(
374
+ "--no-display",
375
+ help="Disable the visible fence display",
376
+ action="store_true",
377
+ )
378
+ parser.add_argument(
379
+ "--display",
380
+ help="Display device (fb0, hdmi, pygame)",
381
+ default="fb0",
382
+ )
383
+ parser.add_argument(
384
+ "--serve", "-s",
385
+ help="Run as a service (for systemd)",
386
+ action="store_true",
387
+ )
388
+ parser.add_argument(
389
+ "--power",
390
+ choices=["battery", "shore"],
391
+ default="battery",
392
+ help="Power source",
393
+ )
394
+
395
+ args = parser.parse_args()
396
+
397
+ console = cls(
398
+ fence_config=args.fence,
399
+ model_path=args.model,
400
+ model_dir=args.model_dir,
401
+ enable_voice=args.voice,
402
+ enable_display=not args.no_display,
403
+ display_device=args.display,
404
+ power_source=args.power,
405
+ )
406
+
407
+ console.serve()
408
+
409
+
410
+ if __name__ == "__main__":
411
+ ShepherdsConsole.main()
@@ -0,0 +1,130 @@
1
+ """Console entry point for shepherds-console."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import http.server
7
+ import sys
8
+
9
+ from shepherds_console import ShepherdsConsole
10
+
11
+
12
+ def _build_demo_console() -> ShepherdsConsole:
13
+ """Build a demo console with sample data."""
14
+ c = ShepherdsConsole()
15
+
16
+ # Pastures
17
+ c.add_pasture("translation-farm", mode="plow", capacity=12)
18
+ c.add_pasture("code-review-meadow", mode="plow", capacity=8)
19
+ c.add_pasture("idle-grove", mode="fallow", capacity=5)
20
+
21
+ # Fences
22
+ c.add_fence("token-budget", limit=500_000, action="throttle")
23
+ c.add_fence("rate-limit", limit=1000, action="block")
24
+ c.add_fence("context-window", limit=128_000, action="alert")
25
+
26
+ # Animals
27
+ c.add_animal("translator-7", pasture="translation-farm", role="flux")
28
+ c.add_animal("translator-12", pasture="translation-farm", role="flux")
29
+ c.add_animal("reviewer-3", pasture="code-review-meadow", role="sentinel")
30
+ c.add_animal("reviewer-5", pasture="code-review-meadow", role="sentinel")
31
+ c.add_animal("scout-1", pasture="idle-grove", role="scout", health="offline")
32
+
33
+ # Simulate some activity
34
+ c.fences["token-budget"].consumed = 342_000
35
+ c.fences["rate-limit"].consumed = 847
36
+ c.fences["context-window"].consumed = 91_000
37
+
38
+ for _ in range(142):
39
+ c.complete_task("translator-7", cost=200)
40
+ for _ in range(87):
41
+ c.complete_task("translator-12", cost=150)
42
+ for _ in range(53):
43
+ c.complete_task("reviewer-3")
44
+ for _ in range(31):
45
+ c.complete_task("reviewer-5")
46
+
47
+ c.pastures["translation-farm"].tasks_in_progress = 3
48
+ c.pastures["code-review-meadow"].tasks_in_progress = 1
49
+ c.pastures["translation-farm"].throughput = 7.2
50
+ c.pastures["code-review-meadow"].throughput = 2.8
51
+
52
+ return c
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> int:
56
+ parser = argparse.ArgumentParser(
57
+ prog="shepherds-console",
58
+ description="🐑 The Shepherd's Console — single-pane operations for working animal infrastructure.",
59
+ )
60
+ mode = parser.add_mutually_exclusive_group()
61
+ mode.add_argument(
62
+ "--web",
63
+ action="store_true",
64
+ help="Serve web dashboard instead of terminal output",
65
+ )
66
+ mode.add_argument(
67
+ "--json",
68
+ action="store_true",
69
+ help="Output JSON status",
70
+ )
71
+ parser.add_argument(
72
+ "--port",
73
+ type=int,
74
+ default=8080,
75
+ help="Port for web server (default: 8080)",
76
+ )
77
+ parser.add_argument(
78
+ "--host",
79
+ default="127.0.0.1",
80
+ help="Host for web server (default: 127.0.0.1)",
81
+ )
82
+ parser.add_argument(
83
+ "--demo",
84
+ action="store_true",
85
+ help="Use demo data (no live connections)",
86
+ )
87
+
88
+ args = parser.parse_args(argv)
89
+
90
+ console = _build_demo_console() if args.demo else ShepherdsConsole()
91
+
92
+ if args.json:
93
+ import json
94
+ print(json.dumps(console.status(), indent=2))
95
+ return 0
96
+
97
+ if args.web:
98
+ html_content = console.render_html()
99
+
100
+ class Handler(http.server.BaseHTTPRequestHandler):
101
+ def do_GET(self):
102
+ self.send_response(200)
103
+ self.send_header("Content-Type", "text/html; charset=utf-8")
104
+ self.end_headers()
105
+ self.wfile.write(
106
+ console.render_html().encode()
107
+ )
108
+
109
+ def log_message(self, *a):
110
+ pass # suppress access logs
111
+
112
+ server = http.server.HTTPServer((args.host, args.port), Handler)
113
+ print(
114
+ f"🐑 Shepherd's Console serving at "
115
+ f"http://{args.host}:{args.port}"
116
+ )
117
+ try:
118
+ server.serve_forever()
119
+ except KeyboardInterrupt:
120
+ print("\nShutting down.")
121
+ server.shutdown()
122
+ return 0
123
+
124
+ # Terminal mode
125
+ print(console.render_terminal())
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())