Skip to content

Commit a5442e8

Browse files
stonks-gitclaude
andcommitted
Add peripheral architecture: Telegram + stdin input, unified queue
- New src/telegram_peripheral.py: raw httpx Telegram Bot API (long polling, owner-only auth, reply_fn routing) - New src/stdin_peripheral.py: stdin factored out as a peripheral - src/loop.py: replace hardcoded stdin with unified input_queue, add reply_fn routing to output block and all introspection commands - src/idle.py: rename dmn_queue to input_queue (shared with all peripherals) - src/main.py: wire shared input_queue, stdin + telegram peripherals Zero new dependencies (uses httpx from anthropic/google-genai transitive deps). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0f04799 commit a5442e8

5 files changed

Lines changed: 416 additions & 114 deletions

File tree

src/idle.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@ class IdleLoop:
4848
Heartbeat interval adapts to activity level (biological anticorrelation).
4949
"""
5050

51-
def __init__(self, config, layers, memory, dmn_queue: asyncio.Queue):
51+
def __init__(self, config, layers, memory, input_queue: asyncio.Queue):
5252
self.config = config
5353
self.layers = layers
5454
self.memory = memory
55-
self.dmn_queue = dmn_queue
55+
self.input_queue = input_queue
5656
self.last_activity = datetime.now(timezone.utc)
5757
self.heartbeat_count = 0
5858
self._recent_topics: list[str] = [] # Track for entropy guard
@@ -154,10 +154,10 @@ async def _heartbeat(self):
154154

155155
# Queue for cognitive loop (non-blocking)
156156
try:
157-
self.dmn_queue.put_nowait(candidate)
157+
self.input_queue.put_nowait(candidate)
158158
logger.debug(f"DMN queued [{channel}]: {thought[:60]}")
159159
except asyncio.QueueFull:
160-
logger.debug("DMN queue full — dropping candidate")
160+
logger.debug("Input queue full — dropping DMN candidate")
161161

162162
async def _sample_memory(self) -> dict | None:
163163
"""Stochastic memory sampling with biases toward interesting content."""

src/loop.py

Lines changed: 134 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import asyncio
2020
import logging
2121
import os
22-
import sys
2322
from datetime import datetime, timezone
2423

2524
import numpy as np
@@ -234,16 +233,19 @@ async def _embed_text(memory, text: str) -> np.ndarray:
234233
return np.array(vec, dtype=np.float32)
235234

236235

237-
async def cognitive_loop(config, layers, memory, shutdown_event, dmn_queue=None):
236+
async def cognitive_loop(config, layers, memory, shutdown_event, input_queue: asyncio.Queue):
238237
"""The main attention-agnostic cognitive loop.
239238
240239
All input sources feed the same pipeline via AttentionAllocator.
241240
The cognitive state report enables metacognition through a single
242241
context window — Python pre-processing is subconscious, the LLM
243242
call is conscious, injection is the bridge.
244243
244+
Peripherals (stdin, Telegram, DMN, etc.) push AttentionCandidate
245+
objects into the shared input_queue. The loop drains it each cycle.
246+
245247
Args:
246-
dmn_queue: Optional asyncio.Queue of DMN AttentionCandidate objects
248+
input_queue: Unified asyncio.Queue — all peripherals push here
247249
produced by the idle loop. Consumed when no user input.
248250
"""
249251
logger.info("Cognitive loop started. Awaiting input...")
@@ -317,71 +319,77 @@ async def cognitive_loop(config, layers, memory, shutdown_event, dmn_queue=None)
317319
while not shutdown_event.is_set():
318320
try:
319321
# ── Collect input candidates ──────────────────────────────
322+
# Drain unified input queue — all peripherals push here.
320323

321-
# Read user input (non-blocking to allow shutdown)
322-
print("you> ", end="", flush=True)
323-
loop = asyncio.get_event_loop()
324324
try:
325-
user_input = await asyncio.wait_for(
326-
loop.run_in_executor(None, sys.stdin.readline),
327-
timeout=1.0,
325+
candidate = await asyncio.wait_for(
326+
input_queue.get(), timeout=1.0,
328327
)
329328
except asyncio.TimeoutError:
330-
# No user input — check DMN queue for internal candidates
331-
has_dmn = False
332-
if dmn_queue:
333-
while not dmn_queue.empty():
334-
try:
335-
dmn_candidate = dmn_queue.get_nowait()
336-
attention.add_candidate(dmn_candidate)
337-
has_dmn = True
338-
except asyncio.QueueEmpty:
339-
break
340-
if not has_dmn:
329+
# Nothing arrived — check if there are leftover losers
330+
if attention.queue_size == 0:
341331
continue
342-
# DMN candidates available — fall through to attention allocation
343-
user_input = None
344-
else:
345-
user_input = user_input.strip() if user_input else None
346-
347-
# ── Handle user input (if present) ─────────────────────────
348-
if user_input:
349-
if user_input.lower() in ("exit", "quit", "/quit"):
350-
logger.info("Final scratch flush before shutdown...")
351-
await _flush_scratch_through_exit_gate(
352-
exit_gate, memory, layers, conversation,
353-
outcome_tracker=outcome_tracker, gut=gut,
354-
)
355-
logger.info("User requested shutdown.")
356-
shutdown_event.set()
332+
# Losers from previous cycle still in queue — process them
333+
candidate = None
334+
335+
# Process first candidate + drain remaining
336+
got_input = False
337+
candidates_this_cycle = []
338+
if candidate is not None:
339+
candidates_this_cycle.append(candidate)
340+
while not input_queue.empty():
341+
try:
342+
extra = input_queue.get_nowait()
343+
candidates_this_cycle.append(extra)
344+
except asyncio.QueueEmpty:
357345
break
358346

359-
# ── Introspection commands ────────────────────────────
360-
if user_input.startswith("/"):
361-
handled = await _handle_command(
362-
user_input, config, layers, memory, model_name,
363-
conversation, exchange_count, entry_gate, exit_gate,
364-
attention, gut=gut, bootstrap=bootstrap,
365-
)
366-
if handled:
367-
continue
347+
# Handle each candidate
348+
should_break = False
349+
for cand in candidates_this_cycle:
350+
if "external" in cand.source_tag:
351+
text = cand.content.strip()
352+
reply_fn = cand.metadata.get("reply_fn")
353+
354+
# ── Quit command ──────────────────────────────
355+
if text.lower() in ("exit", "quit", "/quit"):
356+
logger.info("Final scratch flush before shutdown...")
357+
await _flush_scratch_through_exit_gate(
358+
exit_gate, memory, layers, conversation,
359+
outcome_tracker=outcome_tracker, gut=gut,
360+
)
361+
logger.info("User requested shutdown.")
362+
shutdown_event.set()
363+
should_break = True
364+
break
365+
366+
# ── Introspection commands ────────────────────
367+
if text.startswith("/"):
368+
handled = await _handle_command(
369+
text, config, layers, memory, model_name,
370+
conversation, exchange_count, entry_gate, exit_gate,
371+
attention, gut=gut, bootstrap=bootstrap,
372+
reply_fn=reply_fn,
373+
)
374+
if handled:
375+
continue
368376

369-
# ── Add user message as attention candidate ───────────
370-
try:
371-
user_embedding = await _embed_text(memory, user_input)
372-
except Exception as e:
373-
logger.warning(f"Failed to embed user input: {e}")
374-
user_embedding = None
377+
# ── Embed if needed (stdin peripheral skips embedding) ──
378+
if cand.embedding is None:
379+
try:
380+
cand.embedding = await _embed_text(memory, text)
381+
except Exception as e:
382+
logger.warning(f"Failed to embed input: {e}")
375383

376-
user_candidate = AttentionCandidate(
377-
content=user_input,
378-
source_tag="external_user",
379-
embedding=user_embedding,
380-
)
381-
attention.add_candidate(user_candidate)
384+
# Add to attention allocator (external or internal)
385+
attention.add_candidate(cand)
386+
got_input = True
387+
388+
if should_break:
389+
break
382390

383-
elif not user_input and attention.queue_size == 0:
384-
# No user input and no DMN candidates — nothing to process
391+
if not got_input and attention.queue_size == 0:
392+
# No input and no pending candidates — nothing to process
385393
continue
386394

387395
# ── Attention allocation ──────────────────────────────────
@@ -623,10 +631,14 @@ async def _call_retry():
623631

624632
# ── Output ────────────────────────────────────────────────
625633

626-
# For external_user sources, print the reply
634+
# Route reply to the peripheral that sent the input
627635
if "external" in winner.source_tag:
628636
prefix = "agent" if not escalated else "agent[S2]"
629-
print(f"\n{prefix}> {reply}\n")
637+
reply_fn = winner.metadata.get("reply_fn")
638+
if reply_fn is not None:
639+
await reply_fn(f"{prefix}> {reply}")
640+
else:
641+
print(f"\n{prefix}> {reply}\n")
630642
else:
631643
# Internal thoughts — log only, don't print
632644
logger.info(f"Internal thought response: {reply[:200]}")
@@ -766,97 +778,119 @@ async def _handle_command(
766778
config, layers, memory, model_name,
767779
conversation, exchange_count, entry_gate, exit_gate,
768780
attention, gut=None, bootstrap=None,
781+
reply_fn=None,
769782
) -> bool:
770783
"""Handle introspection commands. Returns True if handled."""
771784

785+
async def _send(text: str):
786+
"""Route output to the peripheral that sent the command."""
787+
if reply_fn is not None:
788+
await reply_fn(text)
789+
else:
790+
print(text)
791+
772792
if command == "/identity":
773-
print("\n" + layers.render_identity_full() + "\n")
793+
await _send("\n" + layers.render_identity_full() + "\n")
774794
return True
775795

776796
if command == "/identity-hash":
777-
print("\n" + layers.render_identity_hash() + "\n")
797+
await _send("\n" + layers.render_identity_hash() + "\n")
778798
return True
779799

780800
if command == "/containment":
781-
print(f"\nTrust level: {config.containment.trust_level}")
782-
print(f"Self-spawn: {config.containment.self_spawn}")
783-
print(f"Self-migration: {config.containment.self_migration}")
784-
print(f"Network: {config.containment.network_mode}")
785-
print(f"Allowed endpoints: {config.containment.allowed_endpoints}")
786-
print(f"Can modify containment: {config.containment.can_modify_containment}\n")
801+
lines = [
802+
f"\nTrust level: {config.containment.trust_level}",
803+
f"Self-spawn: {config.containment.self_spawn}",
804+
f"Self-migration: {config.containment.self_migration}",
805+
f"Network: {config.containment.network_mode}",
806+
f"Allowed endpoints: {config.containment.allowed_endpoints}",
807+
f"Can modify containment: {config.containment.can_modify_containment}\n",
808+
]
809+
await _send("\n".join(lines))
787810
return True
788811

789812
if command == "/status":
790-
print(f"\nAgent: {layers.manifest.get('agent_id')}")
791-
print(f"Phase: {layers.manifest.get('phase')}")
792-
print(f"System 1: {model_name}")
793-
print(f"Layer 0: v{layers.layer0.get('version')}, {len(layers.layer0.get('values', []))} values")
794-
print(f"Layer 1: v{layers.layer1.get('version')}, {len(layers.layer1.get('active_goals', []))} goals")
795813
mc = await memory.memory_count()
796-
print(f"Memories: {mc}")
797-
print(f"Conversation: {len(conversation)} messages")
798-
print(f"Exchanges since flush: {exchange_count}/{EXIT_GATE_FLUSH_INTERVAL}")
799-
print(f"Attention queue: {attention.queue_size} pending")
800-
print(f"Gut: {gut.gut_summary()}\n")
814+
lines = [
815+
f"\nAgent: {layers.manifest.get('agent_id')}",
816+
f"Phase: {layers.manifest.get('phase')}",
817+
f"System 1: {model_name}",
818+
f"Layer 0: v{layers.layer0.get('version')}, {len(layers.layer0.get('values', []))} values",
819+
f"Layer 1: v{layers.layer1.get('version')}, {len(layers.layer1.get('active_goals', []))} goals",
820+
f"Memories: {mc}",
821+
f"Conversation: {len(conversation)} messages",
822+
f"Exchanges since flush: {exchange_count}/{EXIT_GATE_FLUSH_INTERVAL}",
823+
f"Attention queue: {attention.queue_size} pending",
824+
f"Gut: {gut.gut_summary()}\n",
825+
]
826+
await _send("\n".join(lines))
801827
return True
802828

803829
if command == "/gate":
804-
print(f"\nEntry gate stats: {entry_gate.stats}")
805-
print(f"Exit gate stats: {exit_gate.stats}")
806-
print(f"Exchanges since flush: {exchange_count}/{EXIT_GATE_FLUSH_INTERVAL}\n")
830+
lines = [
831+
f"\nEntry gate stats: {entry_gate.stats}",
832+
f"Exit gate stats: {exit_gate.stats}",
833+
f"Exchanges since flush: {exchange_count}/{EXIT_GATE_FLUSH_INTERVAL}\n",
834+
]
835+
await _send("\n".join(lines))
807836
return True
808837

809838
if command == "/memories":
810839
mc = await memory.memory_count()
811-
print(f"\nTotal memories: {mc}")
840+
lines = [f"\nTotal memories: {mc}"]
812841
if mc > 0:
813842
rows = await memory.pool.fetch(
814843
"SELECT id, content, importance, confidence, created_at "
815844
"FROM memories ORDER BY created_at DESC LIMIT 5"
816845
)
817846
for r in rows:
818-
print(f" [{r['id']}] imp={r['importance']:.2f} "
819-
f"conf={r['confidence']:.2f} | {r['content'][:70]}")
820-
print()
847+
lines.append(
848+
f" [{r['id']}] imp={r['importance']:.2f} "
849+
f"conf={r['confidence']:.2f} | {r['content'][:70]}"
850+
)
851+
lines.append("")
852+
await _send("\n".join(lines))
821853
return True
822854

823855
if command == "/flush":
824-
print("Forcing scratch flush through exit gate...")
856+
await _send("Forcing scratch flush through exit gate...")
825857
await _flush_scratch_through_exit_gate(
826858
exit_gate, memory, layers, conversation,
827859
outcome_tracker=getattr(memory, 'outcome_tracker', None),
828860
gut=gut,
829861
)
830-
print(f"Done. Exit gate stats: {exit_gate.stats}\n")
862+
await _send(f"Done. Exit gate stats: {exit_gate.stats}\n")
831863
return True
832864

833865
if command == "/attention":
834-
print(f"\nAttention queue: {attention.queue_size} pending candidates")
835866
centroid = attention.attention_centroid
836-
print(f"Attention centroid: {'computed' if centroid is not None else 'none (no history)'}")
837867
prev = attention.previous_attention_embedding
838-
print(f"Previous embedding: {'set' if prev is not None else 'none'}\n")
868+
lines = [
869+
f"\nAttention queue: {attention.queue_size} pending candidates",
870+
f"Attention centroid: {'computed' if centroid is not None else 'none (no history)'}",
871+
f"Previous embedding: {'set' if prev is not None else 'none'}\n",
872+
]
873+
await _send("\n".join(lines))
839874
return True
840875

841876
if command == "/cost":
842877
from .llm import energy_tracker
843-
print(f"\n{energy_tracker.detailed_report()}\n")
878+
await _send(f"\n{energy_tracker.detailed_report()}\n")
844879
return True
845880

846881
if command == "/readiness":
847882
if bootstrap is not None:
848883
await bootstrap.check_all(memory, layers)
849-
print(f"\n{bootstrap.render_status()}\n")
884+
await _send(f"\n{bootstrap.render_status()}\n")
850885
else:
851-
print("\n[Bootstrap not initialized]\n")
886+
await _send("\n[Bootstrap not initialized]\n")
852887
return True
853888

854889
if command.startswith("/docs"):
855890
# §4.10: Agent reads own docs (read-only access to repo)
856891
parts = command.split(maxsplit=1)
857892
if len(parts) < 2:
858-
print("\nAvailable docs: notes.md, DOCUMENTATION.md, src/*.py")
859-
print("Usage: /docs <filename>\n")
893+
await _send("\nAvailable docs: notes.md, DOCUMENTATION.md, src/*.py\nUsage: /docs <filename>\n")
860894
else:
861895
import pathlib
862896
repo_root = pathlib.Path(__file__).resolve().parent.parent
@@ -865,17 +899,17 @@ async def _handle_command(
865899
try:
866900
target_path = (repo_root / target).resolve()
867901
if not str(target_path).startswith(str(repo_root)):
868-
print("\n[Access denied: path outside repo]\n")
902+
await _send("\n[Access denied: path outside repo]\n")
869903
elif not target_path.exists():
870-
print(f"\n[File not found: {target}]\n")
904+
await _send(f"\n[File not found: {target}]\n")
871905
elif target_path.is_dir():
872906
files = sorted(p.name for p in target_path.iterdir() if p.is_file())
873-
print(f"\nFiles in {target}/: {', '.join(files[:20])}\n")
907+
await _send(f"\nFiles in {target}/: {', '.join(files[:20])}\n")
874908
else:
875909
content = target_path.read_text()[:4000]
876-
print(f"\n--- {target} ---\n{content}\n--- end ---\n")
910+
await _send(f"\n--- {target} ---\n{content}\n--- end ---\n")
877911
except Exception as e:
878-
print(f"\n[Error reading {target}: {e}]\n")
912+
await _send(f"\n[Error reading {target}: {e}]\n")
879913
return True
880914

881915
return False

0 commit comments

Comments
 (0)