Skip to content

Commit c68bbf6

Browse files
author
StackMemory Bot (CLI)
committed
feat(q1): Python SDK, trace event wiring, cache CLI
- Add @StackMemory Python SDK (packages/python-sdk/) with cache, packs, provenance - Zero external deps (stdlib sqlite3), 12 tests passing - Wire TraceEventStore into MCP server — every tool call recorded as ASI-shaped event - Add trace_events, trace_event_stats, trace_event_annotate MCP tools - Add stackmemory cache stats/clear/search CLI commands
1 parent 3e7f8b6 commit c68bbf6

7 files changed

Lines changed: 733 additions & 0 deletions

File tree

packages/python-sdk/pyproject.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "stackmemory"
7+
version = "0.1.0"
8+
description = "Python SDK for StackMemory — content cache, skill packs, and provenance tracking"
9+
readme = "README.md"
10+
license = "MIT"
11+
requires-python = ">=3.11"
12+
authors = [{ name = "StackMemory", email = "hello@stackmemory.ai" }]
13+
keywords = ["stackmemory", "mcp", "skill-packs", "provenance", "token-cache", "ai", "llm"]
14+
classifiers = [
15+
"Development Status :: 3 - Alpha",
16+
"Intended Audience :: Developers",
17+
"License :: OSI Approved :: MIT License",
18+
"Programming Language :: Python :: 3",
19+
"Programming Language :: Python :: 3.11",
20+
"Programming Language :: Python :: 3.12",
21+
"Topic :: Software Development :: Libraries",
22+
]
23+
24+
[project.urls]
25+
Homepage = "https://github.com/stackmemoryai/stackmemory"
26+
Repository = "https://github.com/stackmemoryai/stackmemory/tree/main/packages/python-sdk"
27+
28+
[tool.hatch.build.targets.wheel]
29+
packages = ["stackmemory"]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
stackmemory — Python SDK for StackMemory.
3+
4+
Content cache, skill packs, and provenance tracking for AI agent workflows.
5+
Zero external dependencies. Uses stdlib sqlite3.
6+
"""
7+
8+
from stackmemory.cache import ContentCache
9+
from stackmemory.provenance import ProvenanceStore, TraceEvent
10+
from stackmemory.packs import SkillPackRegistry, load_pack_from_dir
11+
from stackmemory.client import StackMemory
12+
13+
__version__ = "0.1.0"
14+
__all__ = [
15+
"StackMemory",
16+
"ContentCache",
17+
"ProvenanceStore",
18+
"TraceEvent",
19+
"SkillPackRegistry",
20+
"load_pack_from_dir",
21+
]
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Content-addressable cache for LLM context deduplication."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import math
7+
import sqlite3
8+
import time
9+
from dataclasses import dataclass
10+
11+
12+
@dataclass
13+
class CacheEntry:
14+
hash: str
15+
content: str
16+
token_count: int
17+
hit_count: int
18+
first_seen: int
19+
last_seen: int
20+
source: str
21+
22+
23+
@dataclass
24+
class CacheLookupResult:
25+
hit: bool
26+
hash: str
27+
entry: CacheEntry | None = None
28+
tokens_saved: int = 0
29+
30+
31+
@dataclass
32+
class CacheStats:
33+
total_entries: int
34+
total_tokens_cached: int
35+
total_tokens_saved: int
36+
hit_rate: float
37+
top_sources: list[tuple[str, int]]
38+
39+
40+
def estimate_tokens(content: str) -> int:
41+
"""Estimate token count using chars/4 approximation."""
42+
if not content:
43+
return 0
44+
return math.ceil(len(content) / 4)
45+
46+
47+
def hash_content(content: str) -> str:
48+
"""SHA-256 hex digest for content-addressable lookup."""
49+
return hashlib.sha256(content.encode()).hexdigest()
50+
51+
52+
class ContentCache:
53+
"""SQLite-backed content-hash cache with token savings tracking."""
54+
55+
def __init__(self, db: sqlite3.Connection) -> None:
56+
self._db = db
57+
self._init_schema()
58+
59+
def _init_schema(self) -> None:
60+
self._db.executescript("""
61+
CREATE TABLE IF NOT EXISTS content_cache (
62+
hash TEXT PRIMARY KEY,
63+
content TEXT NOT NULL,
64+
token_count INTEGER NOT NULL,
65+
hit_count INTEGER NOT NULL DEFAULT 0,
66+
first_seen INTEGER NOT NULL,
67+
last_seen INTEGER NOT NULL,
68+
source TEXT NOT NULL DEFAULT ''
69+
);
70+
CREATE INDEX IF NOT EXISTS idx_cache_source ON content_cache(source);
71+
""")
72+
73+
def lookup(self, content: str, source: str = "") -> CacheLookupResult:
74+
"""Check if content exists. Increments hit_count on hit."""
75+
h = hash_content(content)
76+
row = self._db.execute(
77+
"SELECT * FROM content_cache WHERE hash = ?", (h,)
78+
).fetchone()
79+
80+
if not row:
81+
return CacheLookupResult(hit=False, hash=h)
82+
83+
now = int(time.time())
84+
self._db.execute(
85+
"UPDATE content_cache SET hit_count = hit_count + 1, last_seen = ? WHERE hash = ?",
86+
(now, h),
87+
)
88+
if source and source != row[5]:
89+
self._db.execute(
90+
"UPDATE content_cache SET source = ? WHERE hash = ?", (source, h)
91+
)
92+
self._db.commit()
93+
94+
entry = CacheEntry(
95+
hash=row[0], content=row[1], token_count=row[2],
96+
hit_count=row[3] + 1, first_seen=row[4],
97+
last_seen=now, source=source or row[5],
98+
)
99+
return CacheLookupResult(hit=True, hash=h, entry=entry, tokens_saved=entry.token_count)
100+
101+
def put(self, content: str, source: str = "") -> CacheEntry:
102+
"""Insert or update a cache entry."""
103+
h = hash_content(content)
104+
token_count = estimate_tokens(content)
105+
now = int(time.time())
106+
107+
existing = self._db.execute(
108+
"SELECT hash FROM content_cache WHERE hash = ?", (h,)
109+
).fetchone()
110+
111+
if existing:
112+
self._db.execute(
113+
"UPDATE content_cache SET hit_count = hit_count + 1, last_seen = ?, source = ? WHERE hash = ?",
114+
(now, source, h),
115+
)
116+
else:
117+
self._db.execute(
118+
"INSERT INTO content_cache (hash, content, token_count, hit_count, first_seen, last_seen, source) VALUES (?, ?, ?, 0, ?, ?, ?)",
119+
(h, content, token_count, now, now, source),
120+
)
121+
self._db.commit()
122+
123+
row = self._db.execute(
124+
"SELECT * FROM content_cache WHERE hash = ?", (h,)
125+
).fetchone()
126+
return CacheEntry(
127+
hash=row[0], content=row[1], token_count=row[2],
128+
hit_count=row[3], first_seen=row[4], last_seen=row[5], source=row[6],
129+
)
130+
131+
def get_stats(self) -> CacheStats:
132+
"""Aggregate cache statistics."""
133+
row = self._db.execute("""
134+
SELECT COUNT(*), COALESCE(SUM(token_count), 0),
135+
COALESCE(SUM(hit_count * token_count), 0),
136+
COALESCE(SUM(hit_count), 0)
137+
FROM content_cache
138+
""").fetchone()
139+
140+
total_entries, total_cached, total_saved, total_hits = row
141+
hit_rate = total_hits / (total_hits + total_entries) if (total_hits + total_entries) > 0 else 0.0
142+
143+
top = self._db.execute("""
144+
SELECT source, SUM(hit_count * token_count) as saved
145+
FROM content_cache WHERE source != ''
146+
GROUP BY source ORDER BY saved DESC LIMIT 10
147+
""").fetchall()
148+
149+
return CacheStats(
150+
total_entries=total_entries,
151+
total_tokens_cached=total_cached,
152+
total_tokens_saved=total_saved,
153+
hit_rate=hit_rate,
154+
top_sources=[(r[0], r[1]) for r in top],
155+
)
156+
157+
def clear(self) -> None:
158+
"""Remove all entries."""
159+
self._db.execute("DELETE FROM content_cache")
160+
self._db.commit()
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""StackMemory SDK — main entry point."""
2+
3+
from __future__ import annotations
4+
5+
import sqlite3
6+
from pathlib import Path
7+
8+
from stackmemory.cache import ContentCache
9+
from stackmemory.packs import SkillPackRegistry
10+
from stackmemory.provenance import ProvenanceStore
11+
12+
13+
def _default_data_dir() -> Path:
14+
import os
15+
home = os.environ.get("HOME") or os.environ.get("USERPROFILE") or "/tmp"
16+
return Path(home) / ".stackmemory"
17+
18+
19+
class StackMemory:
20+
"""Unified entry point for cache, packs, and provenance.
21+
22+
Usage::
23+
24+
from stackmemory import StackMemory
25+
26+
sm = StackMemory()
27+
sm.cache.put("hello world", "test")
28+
sm.packs.list()
29+
sm.provenance.record(TraceEvent(operation="test"))
30+
sm.close()
31+
"""
32+
33+
def __init__(self, data_dir: str | Path | None = None) -> None:
34+
self.data_dir = Path(data_dir) if data_dir else _default_data_dir()
35+
self.data_dir.mkdir(parents=True, exist_ok=True)
36+
37+
self._cache_db = sqlite3.connect(str(self.data_dir / "content-cache.db"))
38+
self._packs_db = sqlite3.connect(str(self.data_dir / "skill-packs.db"))
39+
self._prov_db = sqlite3.connect(str(self.data_dir / "provenance.db"))
40+
41+
self.cache = ContentCache(self._cache_db)
42+
self.packs = SkillPackRegistry(self._packs_db)
43+
self.provenance = ProvenanceStore(self._prov_db)
44+
45+
def close(self) -> None:
46+
"""Close all database connections."""
47+
self._cache_db.close()
48+
self._packs_db.close()
49+
self._prov_db.close()
50+
51+
def __enter__(self) -> "StackMemory":
52+
return self
53+
54+
def __exit__(self, *args: object) -> None:
55+
self.close()

0 commit comments

Comments
 (0)