Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ asyncio.run(main())
- 📄 **Document Management** - Full CRUD operations with type safety
- 🔍 **Semantic Search** - AI-powered document search
- 🤖 **RAG Integration** - Natural language queries to your knowledge base
- 📋 **Clipboard/Memory** - Persistent key-value storage for MCP tools and AI agents
- 📤 **File Uploads** - Support for various file formats
- 🔄 **Version Control** - Document versioning and history
- ⚡ **Async Support** - Both sync and async clients
Expand Down Expand Up @@ -377,6 +378,96 @@ async def rag_operations():
asyncio.run(rag_operations())
```

### Clipboard Operations

The clipboard provides persistent key-value storage for MCP tools and AI agents.

#### Set Named Entry

```python
entry = client.clipboard.set(
name="user_preferences",
value='{"theme": "dark", "lang": "en"}',
content_type="application/json",
encoding="utf-8",
visibility="private",
ttl_seconds=86400 # 24 hours
)

print(f"Created entry: {entry.uuid}")
print(f"Source: {entry.source}") # 'sdk' - automatically set
```

#### Get Entry

```python
# By name
entry = client.clipboard.get(name="user_preferences")
print(entry.value)

# By index (stack access)
latest = client.clipboard.get(idx=0)
```

#### Push to Stack

```python
entry = client.clipboard.push(
value="Processing step 1 result",
content_type="text/plain"
)

print(f"Pushed to index: {entry.idx}")
```

#### Pop from Stack

```python
entry = client.clipboard.pop()
if entry:
print(f"Popped value: {entry.value}")
```

#### List and Delete

```python
# List all entries
response = client.clipboard.list()
for entry in response.entries:
label = entry.name or f"idx:{entry.idx}"
print(f"{label} - source: {entry.source}")

# Delete by name
client.clipboard.delete(name="old_entry")

# Clear all
client.clipboard.clear_all()
```

#### Clipboard Entry Structure

```python
from pluggedinkit import ClipboardEntry, ClipboardSource

# ClipboardEntry fields:
# - uuid: str
# - name: Optional[str] # Semantic key
# - idx: Optional[int] # Stack index
# - value: str
# - content_type: str
# - encoding: "utf-8" | "base64" | "hex"
# - size_bytes: int
# - visibility: "private" | "workspace" | "public"
# - created_by_tool: Optional[str]
# - created_by_model: Optional[str]
# - source: Optional[ClipboardSource] # Auto-set: ui, sdk, or mcp
# - created_at: datetime
# - updated_at: datetime
# - expires_at: Optional[datetime]
```

> **Note**: The `source` field is automatically set to `ClipboardSource.SDK` when using this SDK. It indicates how the entry was created (UI, SDK, or MCP proxy).

### Error Handling

```python
Expand Down
4 changes: 4 additions & 0 deletions src/pluggedinkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
ClipboardDeleteResponse,
ClipboardEncoding,
ClipboardVisibility,
ClipboardSource,
DEFAULT_CLIPBOARD_SOURCE,
# Document types
Document,
DocumentWithContent,
Expand Down Expand Up @@ -61,6 +63,8 @@
"ClipboardDeleteResponse",
"ClipboardEncoding",
"ClipboardVisibility",
"ClipboardSource",
"DEFAULT_CLIPBOARD_SOURCE",
# Document types
"Document",
"DocumentWithContent",
Expand Down
37 changes: 27 additions & 10 deletions src/pluggedinkit/services/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ClipboardPushRequest,
ClipboardResponse,
ClipboardSetRequest,
ClipboardSource,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -78,13 +79,16 @@ def set(
"contentType": content_type,
"encoding": encoding,
"visibility": visibility,
"source": ClipboardSource.SDK.value, # SDK always uses 'sdk' source
}

if created_by_tool:
payload["createdByTool"] = created_by_tool
if created_by_model:
payload["createdByModel"] = created_by_model
if ttl_seconds:
if ttl_seconds is not None:
if ttl_seconds <= 0:
raise ValueError("ttl_seconds must be greater than 0 when provided")
payload["ttlSeconds"] = ttl_seconds

response = self.client.request("POST", "/api/clipboard", json=payload)
Expand All @@ -111,13 +115,16 @@ def push(
"contentType": content_type,
"encoding": encoding,
"visibility": visibility,
"source": ClipboardSource.SDK.value, # SDK always uses 'sdk' source
}

if created_by_tool:
payload["createdByTool"] = created_by_tool
if created_by_model:
payload["createdByModel"] = created_by_model
if ttl_seconds:
if ttl_seconds is not None:
if ttl_seconds <= 0:
raise ValueError("ttl_seconds must be greater than 0 when provided")
payload["ttlSeconds"] = ttl_seconds

response = self.client.request("POST", "/api/clipboard/push", json=payload)
Expand Down Expand Up @@ -165,11 +172,13 @@ def clear_all(self) -> int:

for entry in entries:
try:
success = False
if entry.name:
self.delete(name=entry.name)
success = self.delete(name=entry.name)
elif entry.idx is not None:
self.delete(idx=entry.idx)
deleted += 1
success = self.delete(idx=entry.idx)
if success:
Comment on lines +177 to +180

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): clear_all now depends on delete returning a truthy value, which may change existing behavior.

deleted used to increment after every delete call unless an exception was raised. Now it only increments when delete returns truthy, which changes semantics if delete historically returned None or didn’t signal success. That could cause clear_all to underreport deletions. Consider keeping the old behavior (increment on non-exception) or update delete to reliably return a boolean and document the change in behavior.

deleted += 1
except PluggedInError:
pass

Expand Down Expand Up @@ -236,13 +245,16 @@ async def set(
"contentType": content_type,
"encoding": encoding,
"visibility": visibility,
"source": ClipboardSource.SDK.value, # SDK always uses 'sdk' source
}

if created_by_tool:
payload["createdByTool"] = created_by_tool
if created_by_model:
payload["createdByModel"] = created_by_model
if ttl_seconds:
if ttl_seconds is not None:
if ttl_seconds <= 0:
raise ValueError("ttl_seconds must be greater than 0 when provided")
payload["ttlSeconds"] = ttl_seconds

response = await self.client.request("POST", "/api/clipboard", json=payload)
Expand All @@ -269,13 +281,16 @@ async def push(
"contentType": content_type,
"encoding": encoding,
"visibility": visibility,
"source": ClipboardSource.SDK.value, # SDK always uses 'sdk' source
}

if created_by_tool:
payload["createdByTool"] = created_by_tool
if created_by_model:
payload["createdByModel"] = created_by_model
if ttl_seconds:
if ttl_seconds is not None:
if ttl_seconds <= 0:
raise ValueError("ttl_seconds must be greater than 0 when provided")
payload["ttlSeconds"] = ttl_seconds

response = await self.client.request("POST", "/api/clipboard/push", json=payload)
Expand Down Expand Up @@ -323,11 +338,13 @@ async def clear_all(self) -> int:

for entry in entries:
try:
success = False
if entry.name:
await self.delete(name=entry.name)
success = await self.delete(name=entry.name)
elif entry.idx is not None:
await self.delete(idx=entry.idx)
deleted += 1
success = await self.delete(idx=entry.idx)
if success:
deleted += 1
except PluggedInError:
pass

Expand Down
13 changes: 13 additions & 0 deletions src/pluggedinkit/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,17 @@ class ClipboardVisibility(str, Enum):
PUBLIC = "public"


class ClipboardSource(str, Enum):
"""Clipboard data source"""
UI = "ui"
SDK = "sdk"
MCP = "mcp"


# Default source for backward compatibility with older data
DEFAULT_CLIPBOARD_SOURCE = ClipboardSource.UI


class ClipboardEntry(BaseModel):
"""Clipboard entry model"""
uuid: str
Expand All @@ -295,6 +306,8 @@ class ClipboardEntry(BaseModel):
visibility: ClipboardVisibility = ClipboardVisibility.PRIVATE
created_by_tool: Optional[str] = Field(None, alias="createdByTool")
created_by_model: Optional[str] = Field(None, alias="createdByModel")
# Optional for backward compatibility with older API responses
source: Optional[ClipboardSource] = DEFAULT_CLIPBOARD_SOURCE
created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt")
expires_at: Optional[datetime] = Field(None, alias="expiresAt")
Expand Down