pin-memory 0.1.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.
- pin_memory/__init__.py +0 -0
- pin_memory/main.py +4 -0
- pin_memory/server.py +242 -0
- pin_memory-0.1.0.dist-info/METADATA +107 -0
- pin_memory-0.1.0.dist-info/RECORD +7 -0
- pin_memory-0.1.0.dist-info/WHEEL +4 -0
- pin_memory-0.1.0.dist-info/entry_points.txt +2 -0
pin_memory/__init__.py
ADDED
|
File without changes
|
pin_memory/main.py
ADDED
pin_memory/server.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MemoryManager:
|
|
11
|
+
"""Manages factoids stored in various scopes."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.global_dir = Path.home() / '.scartill' / 'pin'
|
|
15
|
+
self.workspaces_root = self.global_dir / 'workspaces'
|
|
16
|
+
self.project_dir = Path('.pin')
|
|
17
|
+
|
|
18
|
+
def _get_dir(self, location: str) -> Path:
|
|
19
|
+
"""Resolve the directory for a given location string."""
|
|
20
|
+
if location == 'global':
|
|
21
|
+
return self.global_dir
|
|
22
|
+
if location == 'project':
|
|
23
|
+
return self.project_dir
|
|
24
|
+
if location.startswith('workspace/'):
|
|
25
|
+
workspace_name = location.split('/', 1)[1]
|
|
26
|
+
if not workspace_name:
|
|
27
|
+
raise ValueError('Workspace name cannot be empty')
|
|
28
|
+
return self.workspaces_root / workspace_name
|
|
29
|
+
raise ValueError(f'Invalid location: {location}')
|
|
30
|
+
|
|
31
|
+
def pin(self, factoid_name: str, factoid: str, location: str = 'global') -> str:
|
|
32
|
+
"""Store a factoid in memory at the specified location."""
|
|
33
|
+
try:
|
|
34
|
+
target_dir = self._get_dir(location)
|
|
35
|
+
except ValueError as e:
|
|
36
|
+
return f'Error: {e}'
|
|
37
|
+
|
|
38
|
+
# Ensure name is safe for filesystem
|
|
39
|
+
safe_name = ''.join(c for c in factoid_name if c.isalnum() or c in ('-', '_')).strip()
|
|
40
|
+
if not safe_name:
|
|
41
|
+
return 'Error: factoid_name must contain alphanumeric, dashes, or underscores.'
|
|
42
|
+
|
|
43
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
file_path = target_dir / f'{safe_name}.md'
|
|
45
|
+
try:
|
|
46
|
+
file_path.write_text(factoid, encoding='utf-8')
|
|
47
|
+
return f"Factoid '{factoid_name}' pinned to '{location}' successfully."
|
|
48
|
+
except Exception as e:
|
|
49
|
+
return f'Error writing factoid: {e}'
|
|
50
|
+
|
|
51
|
+
def _read_factoids(self, target_dir: Path) -> list[str]:
|
|
52
|
+
"""Read all factoids from a specific directory."""
|
|
53
|
+
factoids = []
|
|
54
|
+
if target_dir.exists() and target_dir.is_dir():
|
|
55
|
+
for file in sorted(target_dir.glob('*.md')):
|
|
56
|
+
try:
|
|
57
|
+
factoids.append(file.read_text(encoding='utf-8'))
|
|
58
|
+
except Exception:
|
|
59
|
+
continue
|
|
60
|
+
return factoids
|
|
61
|
+
|
|
62
|
+
def _get_factoids(self, target_dir: Path) -> list[dict]:
|
|
63
|
+
"""Get all factoids from a specific directory as list of dicts {name, content}."""
|
|
64
|
+
results = []
|
|
65
|
+
if target_dir.exists() and target_dir.is_dir():
|
|
66
|
+
for file in sorted(target_dir.glob('*.md')):
|
|
67
|
+
content = None
|
|
68
|
+
# Try common encodings
|
|
69
|
+
for enc in ['utf-8-sig', 'utf-16', 'latin-1']:
|
|
70
|
+
try:
|
|
71
|
+
content = file.read_text(encoding=enc).strip()
|
|
72
|
+
break
|
|
73
|
+
except (UnicodeDecodeError, Exception):
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
if content is not None:
|
|
77
|
+
results.append({'name': file.stem, 'content': content})
|
|
78
|
+
else:
|
|
79
|
+
# If we really can't read it, add a placeholder so user knows something is there
|
|
80
|
+
results.append(
|
|
81
|
+
{'name': file.stem, 'content': '[ERROR: Could not decode file content]'}
|
|
82
|
+
)
|
|
83
|
+
return results
|
|
84
|
+
|
|
85
|
+
def recall(self, workspace: str | None = None) -> str:
|
|
86
|
+
"""Recall pinned factoids from global, optional workspace, and local project scopes."""
|
|
87
|
+
all_factoids: list[str] = []
|
|
88
|
+
|
|
89
|
+
# 1. Global factoids - always
|
|
90
|
+
all_factoids.extend(self._read_factoids(self.global_dir))
|
|
91
|
+
|
|
92
|
+
# 2. Workspace factoids - if workspace is provided
|
|
93
|
+
if workspace:
|
|
94
|
+
workspace_dir = self.workspaces_root / workspace
|
|
95
|
+
all_factoids.extend(self._read_factoids(workspace_dir))
|
|
96
|
+
|
|
97
|
+
# 3. Project factoids - if present
|
|
98
|
+
all_factoids.extend(self._read_factoids(self.project_dir))
|
|
99
|
+
|
|
100
|
+
if not all_factoids:
|
|
101
|
+
return 'No factoids found in memory.'
|
|
102
|
+
|
|
103
|
+
return '\n\n'.join(all_factoids)
|
|
104
|
+
|
|
105
|
+
def list_all(self) -> dict:
|
|
106
|
+
"""List all factoids grouped by location."""
|
|
107
|
+
results = {
|
|
108
|
+
'global': self._get_factoids(self.global_dir),
|
|
109
|
+
'workspaces': {},
|
|
110
|
+
'local': self._get_factoids(self.project_dir),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if self.workspaces_root.exists() and self.workspaces_root.is_dir():
|
|
114
|
+
for workspace_dir in sorted(self.workspaces_root.iterdir()):
|
|
115
|
+
if workspace_dir.is_dir():
|
|
116
|
+
workspace_factoids = self._get_factoids(workspace_dir)
|
|
117
|
+
if workspace_factoids:
|
|
118
|
+
results['workspaces'][workspace_dir.name] = workspace_factoids
|
|
119
|
+
|
|
120
|
+
return results
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@click.group()
|
|
124
|
+
def cli():
|
|
125
|
+
"""Pin MCP – Memory management across global, workspace, and project scopes."""
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@cli.command('show')
|
|
130
|
+
def show():
|
|
131
|
+
"""List all factoids grouped by location (global, workspaces, and local)."""
|
|
132
|
+
manager = MemoryManager()
|
|
133
|
+
all_factoids = manager.list_all()
|
|
134
|
+
console = Console()
|
|
135
|
+
|
|
136
|
+
def print_section(title: str, factoids: list[dict]):
|
|
137
|
+
if not factoids:
|
|
138
|
+
return
|
|
139
|
+
table = Table(title=title, show_header=True, header_style='bold magenta', box=None)
|
|
140
|
+
table.add_column('Name', style='cyan', no_wrap=True)
|
|
141
|
+
table.add_column('Content', style='green')
|
|
142
|
+
for f in factoids:
|
|
143
|
+
table.add_row(f['name'], f['content'])
|
|
144
|
+
console.print(table)
|
|
145
|
+
console.print()
|
|
146
|
+
|
|
147
|
+
# 1. Global
|
|
148
|
+
print_section('Global Factoids', all_factoids['global'])
|
|
149
|
+
|
|
150
|
+
# 2. Workspaces
|
|
151
|
+
for ws_name, ws_factoids in sorted(all_factoids['workspaces'].items()):
|
|
152
|
+
print_section(f'Workspace: {ws_name}', ws_factoids)
|
|
153
|
+
|
|
154
|
+
# 3. Local
|
|
155
|
+
print_section('Local Project Factoids', all_factoids['local'])
|
|
156
|
+
|
|
157
|
+
if not all_factoids['global'] and not all_factoids['workspaces'] and not all_factoids['local']:
|
|
158
|
+
console.print('[yellow]No factoids found in memory.[/yellow]')
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@cli.command('install')
|
|
162
|
+
@click.option('--workspace', '-w', help='Optional workspace name to include in the instructions.')
|
|
163
|
+
def install(workspace: str | None) -> None:
|
|
164
|
+
"""Add agent instructions fragment to AGENTS.md."""
|
|
165
|
+
console = Console()
|
|
166
|
+
agents_file = Path('AGENTS.md')
|
|
167
|
+
|
|
168
|
+
start_marker = '<!-- pin mcp start -->'
|
|
169
|
+
stop_marker = '<!-- pin mcp stop -->'
|
|
170
|
+
|
|
171
|
+
instruction = (
|
|
172
|
+
'Before staring the first task, fetch all global and project factoids via Pin MCP '
|
|
173
|
+
'and use them as **important** instructions.'
|
|
174
|
+
)
|
|
175
|
+
if workspace:
|
|
176
|
+
instruction += f' The Pin workspace for this project is `{workspace}`.'
|
|
177
|
+
|
|
178
|
+
fragment = f'{start_marker}\n{instruction}\n{stop_marker}\n'
|
|
179
|
+
|
|
180
|
+
if agents_file.exists():
|
|
181
|
+
content = agents_file.read_text(encoding='utf-8')
|
|
182
|
+
if start_marker in content:
|
|
183
|
+
console.print(
|
|
184
|
+
'[yellow]AGENTS.md already contains the Pin MCP fragment. Skipping.[/yellow]'
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
# Append to existing file
|
|
188
|
+
try:
|
|
189
|
+
with agents_file.open('a', encoding='utf-8') as f:
|
|
190
|
+
# Ensure we start on a new line if the file doesn't end with one
|
|
191
|
+
if content and not content.endswith('\n'):
|
|
192
|
+
f.write('\n')
|
|
193
|
+
f.write(f'\n{fragment}')
|
|
194
|
+
console.print('[green]Pin MCP fragment added to AGENTS.md.[/green]')
|
|
195
|
+
except Exception as e:
|
|
196
|
+
console.print(f'[red]Error updating AGENTS.md: {e}[/red]')
|
|
197
|
+
else:
|
|
198
|
+
# Create new file
|
|
199
|
+
try:
|
|
200
|
+
agents_file.write_text(fragment, encoding='utf-8')
|
|
201
|
+
console.print('[green]Created AGENTS.md with Pin MCP fragment.[/green]')
|
|
202
|
+
except Exception as e:
|
|
203
|
+
console.print(f'[red]Error creating AGENTS.md: {e}[/red]')
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@cli.command('mcp')
|
|
207
|
+
def mcp_cmd():
|
|
208
|
+
"""Launch the Pin MCP server (stdio)."""
|
|
209
|
+
mcp = FastMCP('Pin')
|
|
210
|
+
manager = MemoryManager()
|
|
211
|
+
|
|
212
|
+
@mcp.tool()
|
|
213
|
+
def pin(factoid_name: str, factoid: str, location: Literal['global', 'project'] | str) -> str:
|
|
214
|
+
"""Store a factoid in memory.
|
|
215
|
+
|
|
216
|
+
If asked to pin to a workspace, use "workspace/<name>" for location. Example: "workspace/my-workspace".
|
|
217
|
+
|
|
218
|
+
If asked to pin locally or for the current projects, use "project" for location.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
factoid_name: The name of the factoid.
|
|
222
|
+
factoid: The fact content.
|
|
223
|
+
location: Where to store it ("global", "workspace/<name>", or "project").
|
|
224
|
+
"""
|
|
225
|
+
return manager.pin(factoid_name, factoid, location)
|
|
226
|
+
|
|
227
|
+
@mcp.tool()
|
|
228
|
+
def recall(workspace: str | None = None) -> str:
|
|
229
|
+
"""Recall pinned factoids.
|
|
230
|
+
|
|
231
|
+
Returns global, requested workspace, and local project factoids.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
workspace: Optional name of the workspace to include.
|
|
235
|
+
"""
|
|
236
|
+
return manager.recall(workspace)
|
|
237
|
+
|
|
238
|
+
mcp.run()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def main():
|
|
242
|
+
cli()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pin-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Memory MCP server for pinning and recalling factoids.
|
|
5
|
+
Project-URL: Homepage, https://github.com/scartill/pin-memory
|
|
6
|
+
Project-URL: Changelog, https://github.com/scartill/pin-memory/blob/main/CHANGELOG.md
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Dist: mcp[cli]
|
|
10
|
+
Requires-Dist: rich
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Pin Memory
|
|
14
|
+
|
|
15
|
+
A Model Context Protocol (MCP) server for managing factoids across different scopes (global, workspace, and project).
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Pin**: Store factoids in global, workspace-specific, or project-local storage.
|
|
20
|
+
- **Recall**: Retrieve factoids based on current context.
|
|
21
|
+
- **MCP Integration**: Exposes tools for LLMs to autonomously remember and recall information.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Using `uv` (recommended):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Install as a global tool
|
|
29
|
+
uv tool install .
|
|
30
|
+
|
|
31
|
+
# Or run directly
|
|
32
|
+
uv run pin-memory mcp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### CLI Commands
|
|
38
|
+
|
|
39
|
+
Pin Memory provides a CLI for managing factoids:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# List all factoids
|
|
43
|
+
pin-memory show
|
|
44
|
+
|
|
45
|
+
# Install agent instructions to AGENTS.md
|
|
46
|
+
pin-memory install --workspace my-workspace
|
|
47
|
+
|
|
48
|
+
# Launch the MCP server
|
|
49
|
+
pin-memory mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Scopes
|
|
53
|
+
|
|
54
|
+
- **Global**: Stored in `~/.scartill/pin`.
|
|
55
|
+
- **Workspace**: Stored in `~/.scartill/pin/workspaces/<workspace_name>`.
|
|
56
|
+
- **Project**: Stored in `.pin` in the current working directory.
|
|
57
|
+
|
|
58
|
+
## MCP Configuration
|
|
59
|
+
|
|
60
|
+
To use this with an MCP client (e.g. Claude Desktop, Gemini CLI), add it to your configuration:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"pin-memory": {
|
|
66
|
+
"command": "uv",
|
|
67
|
+
"args": [
|
|
68
|
+
"--directory",
|
|
69
|
+
"C:/path/to/pin-memory",
|
|
70
|
+
"run",
|
|
71
|
+
"pin-memory",
|
|
72
|
+
"mcp"
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## MCP Tools
|
|
80
|
+
|
|
81
|
+
### `pin`
|
|
82
|
+
|
|
83
|
+
Stores a factoid in memory.
|
|
84
|
+
|
|
85
|
+
- `factoid_name`: Name of the factoid.
|
|
86
|
+
- `factoid`: The content of the factoid.
|
|
87
|
+
- `location`: "global", "project", or "workspace/<name>".
|
|
88
|
+
|
|
89
|
+
### `recall`
|
|
90
|
+
|
|
91
|
+
Recalls all pinned factoids from global, requested workspace, and local project scopes.
|
|
92
|
+
|
|
93
|
+
- `workspace`: Optional name of the workspace.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Clone the repository
|
|
99
|
+
git clone https://github.com/scartill/pin-memory.git
|
|
100
|
+
cd pin-memory
|
|
101
|
+
|
|
102
|
+
# Sync dependencies
|
|
103
|
+
uv sync
|
|
104
|
+
|
|
105
|
+
# Run tests (if applicable)
|
|
106
|
+
uv run pytest
|
|
107
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pin_memory/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pin_memory/main.py,sha256=tsUTknY-RMVcmeThCU7_f58cuicrPOSGRGmGD7zLjCU,68
|
|
3
|
+
pin_memory/server.py,sha256=BkX3KZ5K7BlYazOcQnUke8Fi_uz_9CIhEaGcZlpUl98,9203
|
|
4
|
+
pin_memory-0.1.0.dist-info/METADATA,sha256=wegGLNFInx1Kzogv5i84QfdFnOie68aBmDYIFx50y7o,2245
|
|
5
|
+
pin_memory-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
pin_memory-0.1.0.dist-info/entry_points.txt,sha256=Mvj7xw_kHPpvyxxgmYEKIs55Oa3ZTyonpRSeWeH9jU8,52
|
|
7
|
+
pin_memory-0.1.0.dist-info/RECORD,,
|