Skip to content

Commit 93d8b64

Browse files
committed
feat: add ClipboardToolkit — copy text, notes, tasks, links to clipboard
4 tools: copy_to_clipboard, copy_note, copy_task, copy_link. Uses pbcopy (macOS) or xclip (Linux). Accepts optional store + memory for cross-toolkit copy (e.g. "copy that note to my clipboard"). Wired into daemon's _build_toolkits().
1 parent 90cd02d commit 93d8b64

5 files changed

Lines changed: 309 additions & 0 deletions

File tree

src/hive/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
create_stt_provider,
7272
)
7373
from hive.tools.alarms import AlarmChecker, AlarmToolkit
74+
from hive.tools.clipboard import ClipboardToolkit
7475
from hive.tools.comms import CommsToolkit
7576
from hive.tools.knowledge import KnowledgeToolkit
7677
from hive.tools.links import LinkToolkit
@@ -93,6 +94,7 @@
9394
"Hive",
9495
"AgentState",
9596
"AgentStatus",
97+
"ClipboardToolkit",
9698
"CommsToolkit",
9799
"ConversationMemory",
98100
"DaemonAgentAdapter",

src/hive/daemon/loop.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from hive.tools.delegation import DaemonDelegationToolkit
3434
from hive.tools.file import FileToolkit
3535
from hive.tools.git import GitToolkit
36+
from hive.tools.clipboard import ClipboardToolkit
3637
from hive.tools.knowledge import KnowledgeToolkit
3738
from hive.tools.links import LinkToolkit
3839
from hive.tools.memory import MemoryToolkit
@@ -152,6 +153,7 @@ def _build_toolkits(self, agent_id: str) -> list[Any]:
152153
AlarmToolkit(self._store),
153154
KnowledgeToolkit(self._get_memory(agent_id)),
154155
LinkToolkit(self._get_memory(agent_id)),
156+
ClipboardToolkit(store=self._store, memory=self._get_memory(agent_id)),
155157
]
156158
if self._economy_enabled and self._ctx.world is not None:
157159
toolkits.insert(0, WorldToolkit(self._ctx.world, agent_id))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Clipboard toolkit."""
2+
3+
from hive.tools.clipboard.toolkit import ClipboardToolkit
4+
5+
__all__ = ["ClipboardToolkit"]
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Clipboard toolkit — copy text, notes, tasks, and links to the system clipboard."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
import platform
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING
10+
11+
from hive.tools.base import Toolkit, tool
12+
13+
if TYPE_CHECKING:
14+
from hive.memory.semantic import SemanticMemory
15+
from hive.memory.store import HiveStore
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
async def _copy_to_system_clipboard(text: str) -> bool:
21+
"""Copy text to system clipboard. Supports macOS and Linux."""
22+
system = platform.system()
23+
if system == "Darwin":
24+
cmd = ["pbcopy"]
25+
elif system == "Linux":
26+
cmd = ["xclip", "-selection", "clipboard"]
27+
else:
28+
logger.warning("Clipboard not supported on %s", system)
29+
return False
30+
31+
try:
32+
proc = await asyncio.create_subprocess_exec(
33+
*cmd,
34+
stdin=asyncio.subprocess.PIPE,
35+
stdout=asyncio.subprocess.PIPE,
36+
stderr=asyncio.subprocess.PIPE,
37+
)
38+
await asyncio.wait_for(proc.communicate(input=text.encode()), timeout=5)
39+
return proc.returncode == 0
40+
except Exception as e:
41+
logger.warning("Clipboard copy failed: %s", e)
42+
return False
43+
44+
45+
class ClipboardToolkit(Toolkit):
46+
"""Tools for copying content to the system clipboard.
47+
48+
Usage:
49+
# With access to store + memory (can copy tasks, notes, links):
50+
tk = ClipboardToolkit(store=hive_store, memory=semantic_memory)
51+
52+
# Standalone (copy text only):
53+
tk = ClipboardToolkit()
54+
"""
55+
56+
def __init__(
57+
self,
58+
store: HiveStore | None = None,
59+
memory: SemanticMemory | None = None,
60+
db_path: str | Path | None = None,
61+
memory_dir: str | Path | None = None,
62+
) -> None:
63+
self._store: HiveStore | None = None
64+
self._memory: SemanticMemory | None = None
65+
self._memory_dir: Path | None = None
66+
self._initialized = False
67+
68+
if store is not None:
69+
self._store = store
70+
self._initialized = True
71+
elif db_path is not None:
72+
from hive.memory.store import HiveStore as _Store
73+
74+
self._store = _Store(Path(db_path))
75+
76+
if memory is not None:
77+
self._memory = memory
78+
elif memory_dir is not None:
79+
self._memory_dir = Path(memory_dir)
80+
81+
def bind(self, agent_id: str) -> None:
82+
super().bind(agent_id)
83+
if self._memory_dir is not None:
84+
from hive.memory.semantic import SemanticMemory
85+
86+
self._memory = SemanticMemory(self._memory_dir, agent_id)
87+
88+
def rebind(self, agent_id: str) -> None:
89+
super().rebind(agent_id)
90+
if self._memory_dir is not None:
91+
from hive.memory.semantic import SemanticMemory
92+
93+
self._memory = SemanticMemory(self._memory_dir, agent_id)
94+
95+
async def _ensure_init(self) -> None:
96+
if not self._initialized and self._store is not None:
97+
await self._store.initialize()
98+
self._initialized = True
99+
100+
@property
101+
def instructions(self) -> str:
102+
return "You can copy text, notes, tasks, or links to the user's clipboard."
103+
104+
@tool()
105+
async def copy_to_clipboard(self, text: str) -> str:
106+
"""Copy text to the system clipboard.
107+
108+
Args:
109+
text: The text to copy.
110+
"""
111+
ok = await _copy_to_system_clipboard(text)
112+
if ok:
113+
preview = text[:80] + ("..." if len(text) > 80 else "")
114+
return f"Copied to clipboard: {preview}"
115+
return "Failed to copy to clipboard."
116+
117+
@tool()
118+
async def copy_note(self, note_id: str) -> str:
119+
"""Copy a note's content to the clipboard.
120+
121+
Args:
122+
note_id: The note/memory ID to copy.
123+
"""
124+
if self._memory is None:
125+
return "No knowledge base available."
126+
127+
record = await self._memory.recall(note_id)
128+
if record is None:
129+
return f"Note {note_id} not found."
130+
131+
ok = await _copy_to_system_clipboard(record.thought)
132+
if ok:
133+
preview = record.thought[:80] + ("..." if len(record.thought) > 80 else "")
134+
return f"Copied note to clipboard: {preview}"
135+
return "Failed to copy to clipboard."
136+
137+
@tool()
138+
async def copy_task(self, task_id: str) -> str:
139+
"""Copy a task's description to the clipboard.
140+
141+
Args:
142+
task_id: The task ID to copy.
143+
"""
144+
if self._store is None:
145+
return "No task store available."
146+
147+
await self._ensure_init()
148+
tasks = await self._store.list_tasks(self._agent_id, "pending")
149+
tasks += await self._store.list_tasks(self._agent_id, "done")
150+
151+
task = next((t for t in tasks if t["task_id"] == task_id), None)
152+
if task is None:
153+
return f"Task {task_id} not found."
154+
155+
text = task["description"]
156+
if task.get("due_date"):
157+
text += f" (due: {task['due_date']})"
158+
159+
ok = await _copy_to_system_clipboard(text)
160+
if ok:
161+
return f"Copied task to clipboard: {text[:80]}"
162+
return "Failed to copy to clipboard."
163+
164+
@tool()
165+
async def copy_link(self, query: str) -> str:
166+
"""Find a saved link by search and copy its URL to the clipboard.
167+
168+
Args:
169+
query: Search query to find the link.
170+
"""
171+
if self._memory is None:
172+
return "No knowledge base available."
173+
174+
results = await self._memory.search(query, top_k=5)
175+
links = [r for r in results if r.metadata.get("type") == "link"]
176+
177+
if not links:
178+
return f"No saved link matching '{query}'."
179+
180+
link = links[0]
181+
url = link.metadata.get("url", "")
182+
if not url:
183+
return "Link found but has no URL."
184+
185+
ok = await _copy_to_system_clipboard(url)
186+
if ok:
187+
title = link.metadata.get("title", url)
188+
return f"Copied URL to clipboard: {title} ({url})"
189+
return "Failed to copy to clipboard."

tests/tools/test_clipboard.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Tests for ClipboardToolkit."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from unittest.mock import AsyncMock, patch
7+
8+
import pytest
9+
10+
from hive.memory.semantic import SemanticMemory
11+
from hive.tools.clipboard.toolkit import ClipboardToolkit
12+
13+
14+
@pytest.fixture
15+
def memory(tmp_path: Path) -> SemanticMemory:
16+
return SemanticMemory(tmp_path, "test-agent")
17+
18+
19+
@pytest.fixture
20+
def toolkit(memory: SemanticMemory) -> ClipboardToolkit:
21+
tk = ClipboardToolkit(memory=memory)
22+
tk.bind("test-agent")
23+
return tk
24+
25+
26+
class TestClipboardToolkit:
27+
def test_standalone_creation(self) -> None:
28+
tk = ClipboardToolkit()
29+
tk.bind("agent")
30+
tools = tk.get_tools()
31+
tool_names = {t.name for t in tools}
32+
assert "copy_to_clipboard" in tool_names
33+
assert "copy_note" in tool_names
34+
assert "copy_task" in tool_names
35+
assert "copy_link" in tool_names
36+
37+
@pytest.mark.asyncio
38+
async def test_copy_to_clipboard(self, toolkit: ClipboardToolkit) -> None:
39+
with patch(
40+
"hive.tools.clipboard.toolkit._copy_to_system_clipboard",
41+
new_callable=AsyncMock,
42+
return_value=True,
43+
):
44+
result = await toolkit.copy_to_clipboard("hello world")
45+
assert "Copied to clipboard" in result
46+
assert "hello world" in result
47+
48+
@pytest.mark.asyncio
49+
async def test_copy_to_clipboard_failure(self, toolkit: ClipboardToolkit) -> None:
50+
with patch(
51+
"hive.tools.clipboard.toolkit._copy_to_system_clipboard",
52+
new_callable=AsyncMock,
53+
return_value=False,
54+
):
55+
result = await toolkit.copy_to_clipboard("test")
56+
assert "Failed" in result
57+
58+
@pytest.mark.asyncio
59+
async def test_copy_note(
60+
self, toolkit: ClipboardToolkit, memory: SemanticMemory
61+
) -> None:
62+
mid = await memory.store("Important meeting notes", {"tags": "work"})
63+
with patch(
64+
"hive.tools.clipboard.toolkit._copy_to_system_clipboard",
65+
new_callable=AsyncMock,
66+
return_value=True,
67+
):
68+
result = await toolkit.copy_note(mid)
69+
assert "Copied note" in result
70+
assert "Important meeting notes" in result
71+
72+
@pytest.mark.asyncio
73+
async def test_copy_note_not_found(self, toolkit: ClipboardToolkit) -> None:
74+
result = await toolkit.copy_note("nonexistent")
75+
assert "not found" in result
76+
77+
@pytest.mark.asyncio
78+
async def test_copy_note_no_memory(self) -> None:
79+
tk = ClipboardToolkit()
80+
tk.bind("agent")
81+
result = await tk.copy_note("some-id")
82+
assert "No knowledge base" in result
83+
84+
@pytest.mark.asyncio
85+
async def test_copy_link(
86+
self, toolkit: ClipboardToolkit, memory: SemanticMemory
87+
) -> None:
88+
await memory.store(
89+
"Python docs",
90+
{"type": "link", "url": "https://python.org", "title": "Python"},
91+
)
92+
with patch(
93+
"hive.tools.clipboard.toolkit._copy_to_system_clipboard",
94+
new_callable=AsyncMock,
95+
return_value=True,
96+
):
97+
result = await toolkit.copy_link("python")
98+
assert "Copied URL" in result
99+
assert "python.org" in result
100+
101+
@pytest.mark.asyncio
102+
async def test_copy_link_not_found(self, toolkit: ClipboardToolkit) -> None:
103+
result = await toolkit.copy_link("nonexistent-thing-xyz")
104+
assert "No saved link" in result
105+
106+
@pytest.mark.asyncio
107+
async def test_copy_task_no_store(self) -> None:
108+
tk = ClipboardToolkit()
109+
tk.bind("agent")
110+
result = await tk.copy_task("task-123")
111+
assert "No task store" in result

0 commit comments

Comments
 (0)