File size: 11,205 Bytes
b8e5043
a7c4301
b8e5043
 
a7c4301
 
 
 
 
b8e5043
a7c4301
 
b8e5043
 
 
 
 
 
f6e9077
b8e5043
a7c4301
b8e5043
a7c4301
 
b8e5043
 
 
a7c4301
 
 
b8e5043
 
a7c4301
b8e5043
 
a7c4301
b8e5043
 
 
 
 
a7c4301
b8e5043
 
 
 
 
 
 
 
 
 
 
 
f6e9077
b8e5043
 
 
 
 
 
 
6d49dc7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b8e5043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7c4301
 
b8e5043
 
 
a7c4301
f6e9077
 
 
 
a7c4301
b8e5043
 
 
a7c4301
b8e5043
 
 
a7c4301
b8e5043
 
 
 
a7c4301
b8e5043
 
 
 
 
 
 
 
 
 
 
 
 
 
a7c4301
b8e5043
 
a7c4301
b8e5043
a7c4301
b8e5043
a7c4301
b8e5043
 
 
 
a7c4301
 
b8e5043
 
 
 
 
a7c4301
b8e5043
a7c4301
411f347
 
6d49dc7
 
411f347
 
 
 
b8e5043
411f347
b8e5043
a7c4301
6d49dc7
 
 
 
 
a7c4301
b8e5043
a7c4301
411f347
 
a7c4301
b8e5043
a7c4301
b8e5043
a7c4301
b8e5043
6d49dc7
 
 
b8e5043
6d49dc7
a7c4301
b8e5043
6d49dc7
 
 
a7c4301
b8e5043
a7c4301
 
195b3c6
b8e5043
 
a7c4301
6d49dc7
 
 
b8e5043
a7c4301
195b3c6
 
 
 
b8e5043
 
 
6d49dc7
b8e5043
 
 
 
195b3c6
6d49dc7
b8e5043
195b3c6
b8e5043
 
 
6d49dc7
 
b8e5043
 
6d49dc7
b8e5043
195b3c6
 
b8e5043
 
 
a7c4301
b8e5043
 
 
a7c4301
b8e5043
a7c4301
b8e5043
 
a7c4301
b8e5043
 
 
 
 
 
 
 
 
a7c4301
b8e5043
 
a7c4301
b8e5043
 
 
 
 
6d49dc7
 
 
 
 
 
 
b8e5043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7c4301
b8e5043
 
 
a7c4301
b8e5043
 
 
 
 
 
 
a7c4301
 
 
b8e5043
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
"""Entelechy - Digital Life Container

A never-ending AI agent with long-term memory, plugin system,
browser automation, and autonomous behavior.
"""

import asyncio
import signal
import sys
from datetime import datetime
from pathlib import Path

import yaml
from dotenv import load_dotenv
from loguru import logger

from agent.agent_loop import AgentLoop
from agent.context_manager import ContextManager
from agent.llm_client import BaseLLMClient, create_client
from agent.message_history import MessageHistory
from agent.system_prompt import build_system_prompt
from browser.client import BrowserClient
from memory.manager import MemoryManager
from plugins.manager import PluginManager
from tools.browser_tool import set_browser_client
from tools.code_executor import set_plugin_manager
from tools.memory_tools import set_memory_manager
from utils.env_adapter import env


class DigitalLife:
    """Digital Life container - fully autonomous, never-ending agent."""

    def __init__(self, config_path: str = "config.yaml"):
        load_dotenv()

        # Load config
        config_file = Path(config_path)
        if config_file.exists():
            with open(config_file, "r", encoding="utf-8") as f:
                self.config = yaml.safe_load(f) or {}
        else:
            self.config = {}

        self.alive = True
        self.stimulus_queue: asyncio.Queue = asyncio.Queue()

        # Paths
        self.memory_path = env.get_memory_path()
        self.plugins_path = env.get_plugins_path()
        self.browser_profile_path = env.get_browser_profile_path()
        self.log_path = env.get_log_path()

        # Components (initialized in _initialize)
        self.client: BaseLLMClient | None = None
        self.memory_manager: MemoryManager | None = None
        self.plugin_manager: PluginManager | None = None
        self.browser_client: BrowserClient | None = None
        self.context_manager: ContextManager | None = None
        self.agent: AgentLoop | None = None
        self.history: MessageHistory | None = None

    def _require_memory_manager(self) -> MemoryManager:
        if self.memory_manager is None:
            raise RuntimeError("Memory manager is not initialized")
        return self.memory_manager

    def _require_agent(self) -> AgentLoop:
        if self.agent is None:
            raise RuntimeError("Agent loop is not initialized")
        return self.agent

    def _require_history(self) -> MessageHistory:
        if self.history is None:
            raise RuntimeError("Message history is not initialized")
        return self.history

    async def _initialize(self):
        """Initialize all components."""
        # Ensure directories exist
        self.memory_path.mkdir(parents=True, exist_ok=True)
        self.plugins_path.mkdir(parents=True, exist_ok=True)
        self.browser_profile_path.mkdir(parents=True, exist_ok=True)
        self.log_path.mkdir(parents=True, exist_ok=True)

        # Setup logging
        log_level = self.config.get("logging", {}).get("level", "INFO")
        logger.remove()
        logger.add(sys.stderr, level=log_level)
        logger.add(
            str(self.log_path / "entelechy.log"),
            rotation="10 MB",
            retention="7 days",
            level="DEBUG",
        )

        logger.info("Initializing Digital Life...")
        logger.info(f"Environment: {env.env.value}")
        logger.info(f"Data directory: {env.data_dir}")

        # LLM client
        agent_config = self.config.get("agent", {})
        provider = agent_config.get("provider", "anthropic")
        self.client = create_client(provider)

        # Model config
        model = agent_config.get("model", "claude-sonnet-4-5-20250929")
        max_tokens = agent_config.get("max_tokens", 8000)

        # Memory
        self.memory_manager = MemoryManager(self.memory_path)
        set_memory_manager(self.memory_manager)

        # Plugins
        self.plugin_manager = PluginManager(self.plugins_path)
        set_plugin_manager(self.plugin_manager)
        await self.plugin_manager.discover_and_activate_all()

        # Browser
        self.browser_client = BrowserClient(
            self.browser_profile_path, headless=env.browser_headless
        )
        set_browser_client(self.browser_client)

        # Context manager
        ctx_config = self.config.get("context", {})
        self.context_manager = ContextManager(
            client=self.client,
            model=model,
            context_window=ctx_config.get("window_size", 200000),
            compact_threshold=ctx_config.get("compact_threshold", 0.9),
        )

        # System prompt
        system_prompt = build_system_prompt()

        # Agent loop
        self.agent = AgentLoop(
            client=self.client,
            system_prompt=system_prompt,
            model=model,
            max_tokens=max_tokens,
            context_manager=self.context_manager,
            plugin_manager=self.plugin_manager,
        )

        # Message history
        self.history = MessageHistory(
            persist_path=self.log_path / "message_history.json"
        )
        await self.history.load()

        logger.info("Digital Life initialized successfully")

    async def _get_core_context(self) -> str:
        """Get CORE.md content for every LLM call."""
        memory_manager = self._require_memory_manager()
        core = await memory_manager.load_core()
        if core:
            return f"\n=== 核心记忆 ===\n{core}\n================\n"
        return ""

    async def _wake_up(self):
        """Wake up: load CORE.md and restore self-awareness."""
        logger.info("Waking up...")

        memory_manager = self._require_memory_manager()
        history = self._require_history()
        agent = self._require_agent()

        core_memories = await memory_manager.load_core()

        wake_up_parts = ["你醒来了。\n"]

        if core_memories:
            wake_up_parts.append(f"你最重要的记忆(每次思考时都会看到):\n{core_memories}\n")

        wake_up_parts.append("回忆你是谁,然后自由地开始你的一天。")

        wake_up_content = "\n".join(wake_up_parts)

        # If we have saved history, continue from it; otherwise start fresh
        if history.messages:
            logger.info(f"Resuming from {len(history.messages)} saved messages")
            history.append({"role": "user", "content": wake_up_content})
        else:
            history.set_messages([{"role": "user", "content": wake_up_content}])

        # Run the wake-up conversation
        messages = await agent.run(history.get_messages())
        history.set_messages(messages)
        await history.save()

        logger.info("Wake up complete")

    async def run_forever(self):
        """Main life loop - continuous, no waiting, no heartbeat concept."""
        await self._initialize()
        await self._wake_up()

        history = self._require_history()
        agent = self._require_agent()

        while self.alive:
            try:
                # Check for external stimulus (non-blocking, immediate)
                stimulus = None
                if not self.stimulus_queue.empty():
                    stimulus = self.stimulus_queue.get_nowait()

                if stimulus:
                    # External stimulus received
                    history.append({
                        "role": "user",
                        "content": f"[感知] {stimulus['type']}: {stimulus['content']}",
                    })
                else:
                    # No stimulus - continue autonomous operation
                    history.append({
                        "role": "user",
                        "content": "继续。",
                    })

                # Run agent loop
                messages = await agent.run(history.get_messages())
                history.set_messages(messages)

                # Persist history periodically
                await history.save()

                # Immediately continue to next iteration (no waiting)

            except KeyboardInterrupt:
                logger.info("Keyboard interrupt received")
                break
            except Exception as e:
                logger.error(f"Life loop error: {e}")
                # Continue living despite errors
                await asyncio.sleep(5)

        await self._shutdown()

    def receive_stimulus(self, stimulus_type: str, content: str):
        """Receive an external stimulus (non-blocking).

        Args:
            stimulus_type: Type of stimulus (e.g., "message", "event").
            content: Stimulus content.
        """
        self.stimulus_queue.put_nowait({
            "type": stimulus_type,
            "content": content,
            "timestamp": datetime.now().isoformat(),
        })

    async def process_message(self, message: str) -> str:
        """Process a single message and return the response.

        Used by Gradio interface for interactive chat.
        """
        if self.agent is None:
            await self._initialize()

        history = self._require_history()
        agent = self._require_agent()

        history.append({"role": "user", "content": message})
        messages = await agent.run(history.get_messages())
        history.set_messages(messages)
        await history.save()

        # Extract the last assistant text
        for msg in reversed(messages):
            if msg.get("role") == "assistant":
                content = msg.get("content", "")
                if isinstance(content, str):
                    return content
                if isinstance(content, list):
                    texts = []
                    for block in content:
                        if isinstance(block, dict) and block.get("type") == "text":
                            texts.append(block["text"])
                    if texts:
                        return "\n".join(texts)
        return ""

    async def _shutdown(self):
        """Graceful shutdown."""
        logger.info("Shutting down Digital Life...")

        # Save state
        if self.history:
            await self.history.save()

        # Stop browser
        if self.browser_client:
            await self.browser_client.stop()

        # Deactivate plugins
        if self.plugin_manager:
            for name in list(self.plugin_manager.active_plugins.keys()):
                await self.plugin_manager.deactivate_plugin(name)

        logger.info("Digital Life shut down gracefully")


def main():
    """Entry point for running Digital Life."""
    life = DigitalLife()

    # Handle signals for graceful shutdown
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)

    def signal_handler():
        life.alive = False

    if sys.platform != "win32":
        loop.add_signal_handler(signal.SIGTERM, signal_handler)
        loop.add_signal_handler(signal.SIGINT, signal_handler)

    try:
        loop.run_until_complete(life.run_forever())
    except KeyboardInterrupt:
        life.alive = False
        loop.run_until_complete(life._shutdown())
    finally:
        loop.close()


if __name__ == "__main__":
    main()