pdo-agent 2.0.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.
pdo/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """PDO (Python Do) — Think. Plan. Do.
2
+
3
+ A terminal-first AI agent that reasons about a goal, plans the work, decides
4
+ whether tools are needed, executes them safely, reviews the result, and replies
5
+ clearly. The public surface intentionally stays small; everything is wired
6
+ together in :mod:`pdo.main`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ __version__ = "2.0.0"
11
+ __all__ = ["__version__", "run_agent"]
12
+
13
+
14
+ def __getattr__(name: str):
15
+ # Lazy re-export so `import pdo` stays lightweight; the agent stack is only
16
+ # pulled in when run_agent is actually used.
17
+ if name == "run_agent":
18
+ from .api import run_agent
19
+
20
+ return run_agent
21
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
pdo/agent/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """The agent package: orchestration, planning, routing, execution and memory.
2
+
3
+ Each module here is intentionally small and single-purpose. ``core.Agent`` wires
4
+ them together; nothing in this package references concrete tools directly — it
5
+ only talks to the tool registry.
6
+ """
pdo/agent/core.py ADDED
@@ -0,0 +1,275 @@
1
+ """The agent core.
2
+
3
+ ``Agent`` coordinates the other components and runs the ReAct-style loop:
4
+ call the model with tools → if it requests tools, execute them and feed results
5
+ back → repeat until the model returns a final answer. It holds no business logic
6
+ of its own; routing, planning, execution and review live in their own modules.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+
15
+ from ..config import Config
16
+ from ..llm import LLMClient
17
+ from ..tools.registry import ToolRegistry
18
+ from .executor import Executor
19
+ from .memory import MemoryStore
20
+ from .messages import Message
21
+ from .planner import Planner
22
+ from .reviewer import Reviewer
23
+ from .router import Router
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Hard cap on tool round-trips per turn, to bound cost and avoid infinite loops.
28
+ MAX_TOOL_ITERATIONS = 8
29
+ # How many past turns of conversation to include as context.
30
+ HISTORY_WINDOW = 20
31
+ # When the active message list grows beyond this, summarise the overflow…
32
+ SUMMARIZE_THRESHOLD = 24
33
+ # …keeping this many of the most recent messages verbatim.
34
+ SUMMARIZE_KEEP = 10
35
+
36
+
37
+ def _load_system_prompt() -> str:
38
+ path = Path(__file__).resolve().parent.parent / "prompts" / "system.md"
39
+ try:
40
+ return path.read_text(encoding="utf-8")
41
+ except OSError:
42
+ logger.warning("Could not read system prompt at %s; using a minimal fallback", path)
43
+ return "You are PDO, a careful and helpful terminal AI agent."
44
+
45
+
46
+ class Agent:
47
+ """Drives a single conversation: plan, call tools, review, respond."""
48
+
49
+ def __init__(
50
+ self,
51
+ config: Config,
52
+ llm: LLMClient,
53
+ registry: ToolRegistry,
54
+ memory: MemoryStore,
55
+ *,
56
+ on_token: Callable[[str], None] | None = None,
57
+ on_tool: Callable[[str, dict], None] | None = None,
58
+ on_tool_result: Callable[[str, str], None] | None = None,
59
+ planning: bool = True,
60
+ depth: int = 0,
61
+ ) -> None:
62
+ self._config = config
63
+ self._llm = llm
64
+ self._registry = registry
65
+ self._memory = memory
66
+ policy = {
67
+ **{name: "deny" for name in getattr(config, "deny_tools", [])},
68
+ **{name: "ask" for name in getattr(config, "ask_tools", [])},
69
+ }
70
+ self._executor = Executor(registry, policy=policy)
71
+ self._router = Router()
72
+ self._planner = Planner(llm)
73
+ self._reviewer = Reviewer()
74
+ self._on_token = on_token or (lambda _token: None)
75
+ self._on_tool = on_tool or (lambda _name, _args: None)
76
+ self._on_tool_result = on_tool_result or (lambda _name, _result: None)
77
+ self._planning = planning
78
+ self._depth = depth
79
+ self._system_prompt = _load_system_prompt()
80
+ # Running token totals for the session (shown in the UI footer).
81
+ self._usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
82
+ # Only the top-level agent may delegate; children get a registry
83
+ # without the delegate tool, so delegation cannot recurse unbounded.
84
+ if depth == 0 and not registry.has("delegate_task"):
85
+ from .delegate import DelegateTool
86
+
87
+ registry.register(DelegateTool(self))
88
+
89
+ def token_usage(self) -> dict[str, int]:
90
+ """Return cumulative token usage for the session."""
91
+ return dict(self._usage)
92
+
93
+ def run_subtask(self, task: str, context: str = "") -> str:
94
+ """Run ``task`` in a fresh child agent and return its final answer.
95
+
96
+ The child gets the same tools (minus delegation), an ephemeral memory,
97
+ and no streaming; its token usage is folded into this agent's totals.
98
+ """
99
+ from .delegate import MAX_DELEGATION_DEPTH
100
+
101
+ if self._depth >= MAX_DELEGATION_DEPTH:
102
+ return "Error: maximum delegation depth reached; do the task yourself."
103
+
104
+ import tempfile
105
+
106
+ child_registry = ToolRegistry()
107
+ for tool in self._registry.all():
108
+ if tool.name != "delegate_task":
109
+ child_registry.register(tool)
110
+
111
+ child = Agent(
112
+ self._config,
113
+ self._llm,
114
+ child_registry,
115
+ MemoryStore(Path(tempfile.mkdtemp(prefix="pdo-subagent-"))),
116
+ on_tool=self._on_tool, # nested tool activity stays visible
117
+ on_tool_result=self._on_tool_result,
118
+ planning=False,
119
+ depth=self._depth + 1,
120
+ )
121
+ prompt = f"{task}\n\nContext:\n{context}" if context.strip() else task
122
+ try:
123
+ answer = child.run_turn(prompt)
124
+ except Exception as exc: # noqa: BLE001 — a failed subtask must not kill the parent
125
+ logger.exception("Sub-agent failed")
126
+ answer = f"Error: sub-agent failed: {exc}"
127
+ for key in self._usage:
128
+ self._usage[key] += child.token_usage().get(key, 0)
129
+ return answer
130
+
131
+ def set_llm(self, llm: LLMClient) -> None:
132
+ """Swap the active language model (used by the ``/models`` command)."""
133
+ self._llm = llm
134
+ self._planner = Planner(llm)
135
+
136
+ def run_turn(self, user_input: str, images: list[str] | None = None) -> str:
137
+ """Process one user turn and return the final (also streamed) answer.
138
+
139
+ ``images`` is an optional list of local image file paths attached to
140
+ this turn; they are sent to the model as data-URL image parts (only
141
+ vision-capable models will make use of them).
142
+ """
143
+ decision = self._router.route(user_input)
144
+
145
+ plan: list[str] = []
146
+ if self._planning and decision.should_plan:
147
+ plan = self._planner.plan(user_input)
148
+
149
+ self._memory.add_message("user", user_input)
150
+ self._maybe_summarize()
151
+ messages = self._build_messages(plan)
152
+ if images:
153
+ # The last built message is this turn's user text (just persisted);
154
+ # replace it with multi-part content carrying the encoded images.
155
+ parts: list[dict] = [{"type": "text", "text": user_input}]
156
+ for path in images:
157
+ encoded = _encode_image(path)
158
+ if encoded:
159
+ parts.append({"type": "image_url", "image_url": {"url": encoded}})
160
+ messages[-1] = Message(role="user", content=parts)
161
+ tools = self._registry.schemas() if decision.expose_tools else None
162
+
163
+ final = ""
164
+ for _ in range(MAX_TOOL_ITERATIONS):
165
+ response = self._llm.complete(
166
+ messages, tools=tools, stream=True, on_token=self._on_token
167
+ )
168
+ if response.usage:
169
+ for key in self._usage:
170
+ self._usage[key] += response.usage.get(key, 0)
171
+ messages.append(
172
+ Message.assistant(content=response.content or None, tool_calls=response.tool_calls)
173
+ )
174
+
175
+ if not response.tool_calls:
176
+ final = response.content or ""
177
+ break
178
+
179
+ for call in response.tool_calls:
180
+ self._on_tool(call.name, _safe_args(call.arguments))
181
+ tool_message = self._executor.execute(call)
182
+ self._on_tool_result(call.name, tool_message.content or "")
183
+ messages.append(tool_message)
184
+ else:
185
+ final = final or "Reached the tool-iteration limit before finishing the task."
186
+
187
+ final = self._reviewer.review(user_input, final)
188
+ self._memory.add_message("assistant", final)
189
+ return final
190
+
191
+ def _build_messages(self, plan: list[str]) -> list[Message]:
192
+ """Assemble the message list: system prompt, summary, plan, history."""
193
+ messages = [Message.system(self._system_prompt)]
194
+ summary = self._memory.summary()
195
+ if summary:
196
+ messages.append(Message.system("Summary of earlier conversation:\n" + summary))
197
+ if plan:
198
+ messages.append(Message.system("Suggested plan:\n" + "\n".join(plan)))
199
+ # Only conversational turns are persisted; within-turn tool messages are
200
+ # kept locally in `messages` during the loop, not across turns.
201
+ for entry in self._memory.history(limit=HISTORY_WINDOW):
202
+ if entry["role"] in ("user", "assistant"):
203
+ messages.append(Message(role=entry["role"], content=entry["content"]))
204
+ return messages
205
+
206
+ def _maybe_summarize(self) -> None:
207
+ """Compress old turns into the session summary to keep context bounded."""
208
+ messages = self._memory.history()
209
+ if len(messages) <= SUMMARIZE_THRESHOLD:
210
+ return
211
+ older = messages[:-SUMMARIZE_KEEP]
212
+ recent = messages[-SUMMARIZE_KEEP:]
213
+ summary = self._summarize(self._memory.summary(), older)
214
+ if summary:
215
+ self._memory.set_summary(summary)
216
+ self._memory.replace_messages(recent)
217
+
218
+ def _summarize(self, previous: str, older: list[dict]) -> str:
219
+ """Produce an updated running summary (best-effort; empty on failure)."""
220
+ convo = "\n".join(f"{m['role']}: {m['content']}" for m in older)
221
+ prompt = (
222
+ f"Existing summary (may be empty):\n{previous or '(none)'}\n\n"
223
+ f"New conversation turns to fold in:\n{convo}\n\n"
224
+ "Write an updated summary."
225
+ )
226
+ try:
227
+ response = self._llm.complete(
228
+ [
229
+ Message.system(
230
+ "You compress a conversation into a concise summary that "
231
+ "preserves key facts, decisions, file paths and the user's "
232
+ "goals. Reply with only the summary."
233
+ ),
234
+ Message.user(prompt),
235
+ ],
236
+ tools=None,
237
+ stream=False,
238
+ )
239
+ except Exception: # noqa: BLE001 — summarisation is best-effort
240
+ logger.exception("Summarisation failed; keeping full history")
241
+ return ""
242
+ return (response.content or "").strip()
243
+
244
+
245
+ _IMAGE_MIME = {
246
+ ".png": "image/png",
247
+ ".jpg": "image/jpeg",
248
+ ".jpeg": "image/jpeg",
249
+ ".gif": "image/gif",
250
+ ".webp": "image/webp",
251
+ }
252
+
253
+
254
+ def _encode_image(path: str) -> str | None:
255
+ """Encode a local image file as a base64 data URL, or None on failure."""
256
+ import base64
257
+
258
+ file = Path(path).expanduser()
259
+ mime = _IMAGE_MIME.get(file.suffix.lower())
260
+ if mime is None or not file.is_file():
261
+ return None
262
+ try:
263
+ data = base64.b64encode(file.read_bytes()).decode("ascii")
264
+ except OSError:
265
+ logger.warning("Could not read image %s", path)
266
+ return None
267
+ return f"data:{mime};base64,{data}"
268
+
269
+
270
+ def _safe_args(raw: str) -> dict:
271
+ try:
272
+ parsed = json.loads(raw or "{}")
273
+ return parsed if isinstance(parsed, dict) else {}
274
+ except json.JSONDecodeError:
275
+ return {}
pdo/agent/delegate.py ADDED
@@ -0,0 +1,56 @@
1
+ """Sub-agent delegation tool.
2
+
3
+ ``delegate_task`` lets the main agent hand a self-contained subtask to a fresh
4
+ child agent that runs its own ReAct loop and returns only its final answer.
5
+ This keeps the parent's context small on large multi-part jobs.
6
+
7
+ The tool lives in the agent package (not ``pdo.tools``) because it is
8
+ intrinsically coupled to the agent: it needs to spawn one. It is registered by
9
+ the top-level agent itself, and deliberately excluded from the registries handed
10
+ to children, so delegation cannot recurse without bound.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import Any
16
+
17
+ from ..tools.base import Tool
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Belt-and-braces recursion cap: children don't get the delegate tool at all,
22
+ # but the depth check also guards any future registry changes.
23
+ MAX_DELEGATION_DEPTH = 2
24
+
25
+
26
+ class DelegateTool(Tool):
27
+ """Adapter exposing ``Agent.run_subtask`` as a model-callable tool."""
28
+
29
+ name = "delegate_task"
30
+ description = (
31
+ "Delegate a self-contained subtask to a fresh sub-agent that has the "
32
+ "same tools and reports back only its final result. Use it to keep "
33
+ "context small when a job has large independent parts (e.g. 'summarise "
34
+ "every file in docs/'). Include ALL information the sub-agent needs — "
35
+ "it cannot see this conversation."
36
+ )
37
+ parameters = {
38
+ "type": "object",
39
+ "properties": {
40
+ "task": {
41
+ "type": "string",
42
+ "description": "The complete, self-contained task for the sub-agent.",
43
+ },
44
+ "context": {
45
+ "type": "string",
46
+ "description": "Optional extra context (paths, constraints, prior findings).",
47
+ },
48
+ },
49
+ "required": ["task"],
50
+ }
51
+
52
+ def __init__(self, parent: Any) -> None: # Any avoids a circular import with core
53
+ self._parent = parent
54
+
55
+ def run(self, task: str, context: str = "", **_: Any) -> str:
56
+ return self._parent.run_subtask(task, context)
pdo/agent/executor.py ADDED
@@ -0,0 +1,87 @@
1
+ """Executes the tool calls requested by the model.
2
+
3
+ Every call is wrapped in exception handling and argument validation: a failing
4
+ or unknown tool returns an error string that is fed back to the model, so a
5
+ single bad tool call never crashes the agent loop.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import time
12
+ from collections.abc import Callable
13
+
14
+ from ..config import get_logs_dir
15
+ from ..tools.base import default_confirm
16
+ from ..tools.registry import ToolRegistry
17
+ from .messages import Message, ToolCall
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class Executor:
23
+ """Runs a single :class:`ToolCall` against the registry.
24
+
25
+ ``policy`` maps a tool name to ``"deny"`` (blocked) or ``"ask"`` (requires
26
+ confirmation). Tools not listed are allowed. ``confirm`` is the callback used
27
+ for ``"ask"`` decisions.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ registry: ToolRegistry,
33
+ policy: dict[str, str] | None = None,
34
+ confirm: Callable[[str], bool] | None = None,
35
+ ) -> None:
36
+ self._registry = registry
37
+ self._policy = policy or {}
38
+ self._confirm = confirm or default_confirm
39
+
40
+ def execute(self, call: ToolCall) -> Message:
41
+ """Execute ``call`` and return a tool-result message for the model."""
42
+ result = self._run(call)
43
+ self._audit(call.name, call.arguments, result)
44
+ return Message.tool(content=result, tool_call_id=call.id, name=call.name)
45
+
46
+ def _audit(self, name: str, arguments: str, result: str) -> None:
47
+ """Append a structured record of the tool call to the audit log."""
48
+ try:
49
+ entry = {
50
+ "ts": time.time(),
51
+ "tool": name,
52
+ "args": arguments,
53
+ "result_preview": (result or "")[:200],
54
+ }
55
+ with (get_logs_dir() / "audit.log").open("a", encoding="utf-8") as handle:
56
+ handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
57
+ except Exception: # noqa: BLE001 — auditing must never break a tool call
58
+ logger.debug("Could not write audit log", exc_info=True)
59
+
60
+ def _run(self, call: ToolCall) -> str:
61
+ if not self._registry.has(call.name):
62
+ return f"Error: unknown tool {call.name!r}."
63
+
64
+ decision = self._policy.get(call.name, "allow")
65
+ if decision == "deny":
66
+ logger.info("Tool %r blocked by policy", call.name)
67
+ return f"Error: tool {call.name!r} is disabled by the current permission policy."
68
+ if decision == "ask" and not self._confirm(
69
+ f"Allow tool {call.name}({call.arguments})?"
70
+ ):
71
+ return "Cancelled: tool call was not approved by the user."
72
+
73
+ try:
74
+ args = json.loads(call.arguments or "{}")
75
+ if not isinstance(args, dict):
76
+ raise ValueError("tool arguments must be a JSON object")
77
+ except (json.JSONDecodeError, ValueError) as exc:
78
+ logger.warning("Bad arguments for tool %r: %s", call.name, exc)
79
+ return f"Error: could not parse arguments for {call.name}: {exc}"
80
+
81
+ try:
82
+ tool = self._registry.get(call.name)
83
+ logger.info("Executing tool %r with args %s", call.name, args)
84
+ return tool.run(**args)
85
+ except Exception as exc: # noqa: BLE001 — keep the loop alive on any tool error
86
+ logger.exception("Tool %r raised", call.name)
87
+ return f"Error: tool {call.name!r} failed: {exc}"
pdo/agent/memory.py ADDED
@@ -0,0 +1,191 @@
1
+ """Local JSON memory store.
2
+
3
+ State is split across:
4
+
5
+ * ``memory.json`` — durable facts, preferences, and the current session name.
6
+ * ``sessions/<name>.json`` — one file per named conversation: a rolling summary
7
+ plus recent messages.
8
+
9
+ Splitting conversations into named sessions lets the user keep separate threads
10
+ (``/new``, ``/resume``) and lets the agent compress old turns into a summary so
11
+ context stays bounded. :func:`get_memory_store` returns a process-wide singleton
12
+ so the agent and the memory tools share the same data.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import re
19
+ import time
20
+ import uuid
21
+ from functools import lru_cache
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from ..config import get_data_dir
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _DEFAULT_MEMORY: dict[str, Any] = {
30
+ "preferences": {},
31
+ "facts": [],
32
+ "current_session": "default",
33
+ }
34
+ _DEFAULT_SESSION: dict[str, Any] = {"summary": "", "messages": []}
35
+
36
+
37
+ def _safe_name(name: str) -> str:
38
+ """Sanitise a session name into a safe file stem."""
39
+ return re.sub(r"[^A-Za-z0-9_.-]", "_", name).strip("_") or "default"
40
+
41
+
42
+ class MemoryStore:
43
+ """Read/write access to PDO's local JSON memory and named sessions."""
44
+
45
+ def __init__(self, data_dir: Path | None = None) -> None:
46
+ self._dir = Path(data_dir) if data_dir is not None else get_data_dir()
47
+ self._memory_path = self._dir / "memory.json"
48
+ self._sessions_dir = self._dir / "sessions"
49
+ self._sessions_dir.mkdir(parents=True, exist_ok=True)
50
+
51
+ self._memory: dict[str, Any] = self._load(self._memory_path, _DEFAULT_MEMORY)
52
+ self._current: str = self._memory.get("current_session") or "default"
53
+ self._migrate_legacy_history()
54
+ self._summary, self._history = self._load_session(self._current)
55
+
56
+ # --- persistence -------------------------------------------------------- #
57
+ def _load(self, path: Path, default: Any) -> Any:
58
+ try:
59
+ if path.exists():
60
+ return json.loads(path.read_text(encoding="utf-8"))
61
+ except (json.JSONDecodeError, OSError) as exc:
62
+ logger.warning("Could not read %s (%s); starting fresh", path, exc)
63
+ return json.loads(json.dumps(default)) # deep copy
64
+
65
+ def _save(self, path: Path, data: Any) -> None:
66
+ try:
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
69
+ except OSError as exc:
70
+ logger.error("Could not write %s: %s", path, exc)
71
+
72
+ def _session_path(self, name: str) -> Path:
73
+ return self._sessions_dir / f"{_safe_name(name)}.json"
74
+
75
+ def _load_session(self, name: str) -> tuple[str, list[dict[str, Any]]]:
76
+ data = self._load(self._session_path(name), _DEFAULT_SESSION)
77
+ if isinstance(data, list): # tolerate an old flat-list format
78
+ return "", data
79
+ return data.get("summary", ""), data.get("messages", [])
80
+
81
+ def _save_session(self) -> None:
82
+ self._save(
83
+ self._session_path(self._current),
84
+ {"summary": self._summary, "messages": self._history},
85
+ )
86
+
87
+ def _migrate_legacy_history(self) -> None:
88
+ """Migrate a pre-sessions history.json into the default session once."""
89
+ legacy = self._dir / "history.json"
90
+ default_path = self._session_path("default")
91
+ if legacy.exists() and not default_path.exists():
92
+ try:
93
+ data = json.loads(legacy.read_text(encoding="utf-8"))
94
+ if isinstance(data, list):
95
+ self._save(default_path, {"summary": "", "messages": data})
96
+ except (json.JSONDecodeError, OSError):
97
+ logger.debug("No legacy history to migrate", exc_info=True)
98
+
99
+ # --- conversation history & summary ------------------------------------ #
100
+ def add_message(self, role: str, content: str) -> None:
101
+ self._history.append({"role": role, "content": content, "ts": time.time()})
102
+ self._save_session()
103
+
104
+ def history(self, limit: int | None = None) -> list[dict[str, Any]]:
105
+ if limit is not None and limit > 0:
106
+ return list(self._history[-limit:])
107
+ return list(self._history)
108
+
109
+ def clear_history(self) -> None:
110
+ self._history = []
111
+ self._summary = ""
112
+ self._save_session()
113
+
114
+ def summary(self) -> str:
115
+ return self._summary
116
+
117
+ def set_summary(self, text: str) -> None:
118
+ self._summary = text
119
+ self._save_session()
120
+
121
+ def replace_messages(self, messages: list[dict[str, Any]]) -> None:
122
+ """Replace the active message list (used after summarising old turns)."""
123
+ self._history = list(messages)
124
+ self._save_session()
125
+
126
+ # --- sessions ----------------------------------------------------------- #
127
+ def current_session(self) -> str:
128
+ return self._current
129
+
130
+ def list_sessions(self) -> list[str]:
131
+ names = {path.stem for path in self._sessions_dir.glob("*.json")}
132
+ names.add(_safe_name(self._current))
133
+ return sorted(names)
134
+
135
+ def switch_session(self, name: str) -> str:
136
+ self._save_session() # persist the session we're leaving
137
+ self._current = _safe_name(name)
138
+ self._memory["current_session"] = self._current
139
+ self._save(self._memory_path, self._memory)
140
+ self._summary, self._history = self._load_session(self._current)
141
+ return self._current
142
+
143
+ def new_session(self, name: str | None = None) -> str:
144
+ return self.switch_session(name or f"session-{int(time.time())}")
145
+
146
+ # --- facts -------------------------------------------------------------- #
147
+ def save_fact(self, text: str, tags: list[str] | None = None) -> str:
148
+ fact_id = uuid.uuid4().hex[:8]
149
+ self._memory.setdefault("facts", []).append(
150
+ {"id": fact_id, "text": text, "tags": tags or [], "created": time.time()}
151
+ )
152
+ self._save(self._memory_path, self._memory)
153
+ return fact_id
154
+
155
+ def search_facts(self, query: str) -> list[dict[str, Any]]:
156
+ needle = query.lower().strip()
157
+ return [
158
+ fact
159
+ for fact in self._memory.get("facts", [])
160
+ if needle in fact["text"].lower()
161
+ or any(needle in tag.lower() for tag in fact.get("tags", []))
162
+ ]
163
+
164
+ def all_facts(self) -> list[dict[str, Any]]:
165
+ return list(self._memory.get("facts", []))
166
+
167
+ def delete_fact(self, fact_id: str) -> bool:
168
+ facts = self._memory.get("facts", [])
169
+ remaining = [fact for fact in facts if fact["id"] != fact_id]
170
+ if len(remaining) == len(facts):
171
+ return False
172
+ self._memory["facts"] = remaining
173
+ self._save(self._memory_path, self._memory)
174
+ return True
175
+
176
+ # --- preferences -------------------------------------------------------- #
177
+ def get_preference(self, key: str, default: Any = None) -> Any:
178
+ return self._memory.get("preferences", {}).get(key, default)
179
+
180
+ def set_preference(self, key: str, value: Any) -> None:
181
+ self._memory.setdefault("preferences", {})[key] = value
182
+ self._save(self._memory_path, self._memory)
183
+
184
+ def preferences(self) -> dict[str, Any]:
185
+ return dict(self._memory.get("preferences", {}))
186
+
187
+
188
+ @lru_cache(maxsize=1)
189
+ def get_memory_store() -> MemoryStore:
190
+ """Return the process-wide memory store (created on first call)."""
191
+ return MemoryStore()