groundmemory 0.2.4__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.
@@ -0,0 +1,30 @@
1
+ """
2
+ groundmemory — local-first, model-agnostic persistent memory for AI agents.
3
+
4
+ Quick start
5
+ -----------
6
+ >>> from groundmemory import MemorySession
7
+ >>> session = MemorySession.create("my_project")
8
+ >>> session.sync()
9
+ >>> print(session.bootstrap()) # inject into system prompt
10
+ >>> result = session.execute_tool("memory_write", content="Alice loves Python.")
11
+ >>> result = session.execute_tool("memory_search", query="Alice")
12
+ >>> session.close()
13
+
14
+ OpenAI adapter
15
+ --------------
16
+ >>> from groundmemory.adapters.openai import get_openai_tools, run_agent_loop
17
+
18
+ Anthropic adapter
19
+ -----------------
20
+ >>> from groundmemory.adapters.anthropic import get_anthropic_tools, run_agent_loop
21
+ """
22
+ from groundmemory.session import MemorySession
23
+ from groundmemory.config import groundmemoryConfig
24
+
25
+ __all__ = [
26
+ "MemorySession",
27
+ "groundmemoryConfig",
28
+ ]
29
+
30
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Adapters — format groundmemory tool schemas for specific LLM provider APIs."""
@@ -0,0 +1,207 @@
1
+ """
2
+ Anthropic adapter — converts groundmemory tool schemas into the format expected
3
+ by the Anthropic Messages API (``tools`` parameter).
4
+
5
+ Usage
6
+ -----
7
+ >>> import anthropic
8
+ >>> from groundmemory.session import MemorySession
9
+ >>> from groundmemory.adapters.anthropic import get_anthropic_tools, handle_tool_calls
10
+ >>>
11
+ >>> session = MemorySession.create("my_project")
12
+ >>> client = anthropic.Anthropic()
13
+ >>>
14
+ >>> response = client.messages.create(
15
+ ... model="claude-opus-4-5",
16
+ ... max_tokens=4096,
17
+ ... system=session.bootstrap(),
18
+ ... messages=[{"role": "user", "content": "What do you remember about Alice?"}],
19
+ ... tools=get_anthropic_tools(),
20
+ ... )
21
+ >>> messages, tool_results = handle_tool_calls(session, response)
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from typing import Any
27
+
28
+ from groundmemory.tools import ALL_TOOLS
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Schema conversion
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def _to_anthropic_tool(schema: dict) -> dict:
37
+ """Convert a single groundmemory SCHEMA dict to an Anthropic tool definition."""
38
+ return {
39
+ "name": schema["name"],
40
+ "description": schema["description"],
41
+ "input_schema": schema["parameters"],
42
+ }
43
+
44
+
45
+ def get_anthropic_tools(names: list[str] | None = None) -> list[dict]:
46
+ """
47
+ Return Anthropic-formatted tool definitions for all (or a subset of) tools.
48
+
49
+ Parameters
50
+ ----------
51
+ names : list[str] | None
52
+ If provided, only include tools whose names are in this list.
53
+
54
+ Returns
55
+ -------
56
+ list[dict]
57
+ Ready to pass as ``tools=...`` in ``client.messages.create()``.
58
+ """
59
+ result = []
60
+ for schema, _ in ALL_TOOLS:
61
+ if names is None or schema["name"] in names:
62
+ result.append(_to_anthropic_tool(schema))
63
+ return result
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Tool-call handling
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def handle_tool_calls(
72
+ session: Any,
73
+ response: Any,
74
+ ) -> tuple[list[dict], list[dict]]:
75
+ """
76
+ Process all tool_use blocks in an Anthropic ``Message`` response.
77
+
78
+ Parameters
79
+ ----------
80
+ session : MemorySession
81
+ response : anthropic.types.Message
82
+
83
+ Returns
84
+ -------
85
+ (assistant_turn, tool_result_turn)
86
+ Two message dicts ready to be appended to your messages list.
87
+ ``assistant_turn`` — the assistant message containing tool_use blocks.
88
+ ``tool_result_turn`` — a user message containing all tool_result blocks.
89
+
90
+ Example
91
+ -------
92
+ >>> asst, results = handle_tool_calls(session, response)
93
+ >>> messages += [asst, results]
94
+ """
95
+ # Build the assistant turn from the raw response content blocks
96
+ content_blocks = []
97
+ tool_use_blocks = []
98
+
99
+ for block in response.content:
100
+ block_dict = block.model_dump()
101
+ content_blocks.append(block_dict)
102
+ if block.type == "tool_use":
103
+ tool_use_blocks.append(block)
104
+
105
+ assistant_turn = {"role": "assistant", "content": content_blocks}
106
+
107
+ if not tool_use_blocks:
108
+ return assistant_turn, {}
109
+
110
+ # Execute each tool and collect results
111
+ tool_results = []
112
+ for block in tool_use_blocks:
113
+ kwargs = block.input if isinstance(block.input, dict) else {}
114
+ result = session.execute_tool(block.name, **kwargs)
115
+ tool_results.append(
116
+ {
117
+ "type": "tool_result",
118
+ "tool_use_id": block.id,
119
+ "content": json.dumps(result),
120
+ }
121
+ )
122
+
123
+ tool_result_turn = {"role": "user", "content": tool_results}
124
+ return assistant_turn, tool_result_turn
125
+
126
+
127
+ def run_agent_loop(
128
+ session: Any,
129
+ client: Any,
130
+ messages: list[dict],
131
+ model: str = "claude-opus-4-5",
132
+ max_tokens: int = 4096,
133
+ system: str = "",
134
+ max_iterations: int = 10,
135
+ **create_kwargs: Any,
136
+ ) -> list[dict]:
137
+ """
138
+ Run a simple agentic loop that keeps calling the model until it stops
139
+ producing tool_use blocks (or ``max_iterations`` is reached).
140
+
141
+ Parameters
142
+ ----------
143
+ session : MemorySession
144
+ client : anthropic.Anthropic
145
+ messages : list[dict] Initial message list (user turns).
146
+ model : str
147
+ max_tokens : int
148
+ system : str System prompt (use session.bootstrap() here).
149
+ max_iterations : int Safety cap.
150
+ **create_kwargs Extra kwargs forwarded to ``client.messages.create``.
151
+
152
+ Returns
153
+ -------
154
+ list[dict] Final message history.
155
+ """
156
+ from groundmemory.bootstrap.compaction import should_flush, get_compaction_prompts
157
+
158
+ tools = get_anthropic_tools()
159
+ # Auto-inject bootstrap if system is empty
160
+ if not system:
161
+ system = session.bootstrap()
162
+
163
+ compaction_cfg = session.config.compaction
164
+ _flushed = False # only flush once per loop
165
+
166
+ for _ in range(max_iterations):
167
+ response = client.messages.create(
168
+ model=model,
169
+ max_tokens=max_tokens,
170
+ system=system,
171
+ messages=messages,
172
+ tools=tools,
173
+ **create_kwargs,
174
+ )
175
+
176
+ asst_turn, result_turn = handle_tool_calls(session, response)
177
+ messages.append(asst_turn)
178
+
179
+ # Check compaction threshold and inject a flush turn if needed
180
+ if not _flushed and compaction_cfg.enabled:
181
+ used = response.usage.input_tokens + response.usage.output_tokens
182
+ if should_flush(used, compaction_cfg):
183
+ prompts = get_compaction_prompts(compaction_cfg)
184
+ messages.append({"role": "user", "content": prompts["user"]})
185
+ # One dedicated flush turn — let the model write to memory
186
+ flush_response = client.messages.create(
187
+ model=model,
188
+ max_tokens=max_tokens,
189
+ system=prompts["system"],
190
+ messages=messages,
191
+ tools=tools,
192
+ **create_kwargs,
193
+ )
194
+ flush_asst, flush_results = handle_tool_calls(session, flush_response)
195
+ messages.append(flush_asst)
196
+ if flush_results:
197
+ messages.append(flush_results)
198
+ _flushed = True
199
+
200
+ # Stop when the model finished without requesting tools
201
+ if response.stop_reason != "tool_use":
202
+ break
203
+
204
+ if result_turn:
205
+ messages.append(result_turn)
206
+
207
+ return messages
@@ -0,0 +1,185 @@
1
+ """
2
+ OpenAI adapter — converts groundmemory tool schemas into the format expected by
3
+ the OpenAI Chat Completions ``tools`` parameter (function-calling API).
4
+
5
+ Usage
6
+ -----
7
+ >>> from openai import OpenAI
8
+ >>> from groundmemory.session import MemorySession
9
+ >>> from groundmemory.adapters.openai import get_openai_tools, handle_tool_calls
10
+ >>>
11
+ >>> session = MemorySession.create("my_project")
12
+ >>> client = OpenAI()
13
+ >>>
14
+ >>> response = client.chat.completions.create(
15
+ ... model="gpt-4o",
16
+ ... messages=[{"role": "system", "content": session.bootstrap()},
17
+ ... {"role": "user", "content": "What do you remember about Alice?"}],
18
+ ... tools=get_openai_tools(),
19
+ ... )
20
+ >>> messages = handle_tool_calls(session, response, messages)
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from typing import Any
26
+
27
+ from groundmemory.tools import ALL_TOOLS, TOOL_RUNNERS
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Schema conversion
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def _to_openai_tool(schema: dict) -> dict:
36
+ """Convert a single groundmemory SCHEMA dict to an OpenAI tool definition."""
37
+ return {
38
+ "type": "function",
39
+ "function": {
40
+ "name": schema["name"],
41
+ "description": schema["description"],
42
+ "parameters": schema["parameters"],
43
+ },
44
+ }
45
+
46
+
47
+ def get_openai_tools(names: list[str] | None = None) -> list[dict]:
48
+ """
49
+ Return OpenAI-formatted tool definitions for all (or a subset of) tools.
50
+
51
+ Parameters
52
+ ----------
53
+ names : list[str] | None
54
+ If provided, only include tools whose names are in this list.
55
+ Useful when you want to expose only a subset of memory tools.
56
+
57
+ Returns
58
+ -------
59
+ list[dict]
60
+ Ready to pass as ``tools=...`` in ``client.chat.completions.create()``.
61
+ """
62
+ result = []
63
+ for schema, _ in ALL_TOOLS:
64
+ if names is None or schema["name"] in names:
65
+ result.append(_to_openai_tool(schema))
66
+ return result
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Tool-call handling
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def handle_tool_calls(
75
+ session: Any,
76
+ response: Any,
77
+ messages: list[dict],
78
+ ) -> list[dict]:
79
+ """
80
+ Process all tool calls in an OpenAI ``ChatCompletion`` response.
81
+
82
+ Executes each tool call against *session*, appends the assistant message
83
+ and all tool result messages to *messages*, and returns the updated list.
84
+
85
+ Parameters
86
+ ----------
87
+ session : MemorySession
88
+ response : openai.types.chat.ChatCompletion
89
+ messages : list[dict] The current conversation message list (mutated in place).
90
+
91
+ Returns
92
+ -------
93
+ list[dict] The same *messages* list with new entries appended.
94
+ """
95
+ choice = response.choices[0]
96
+ assistant_msg = choice.message
97
+
98
+ # Append the assistant turn (includes tool_calls)
99
+ messages.append(assistant_msg.model_dump(exclude_unset=True))
100
+
101
+ if not assistant_msg.tool_calls:
102
+ return messages
103
+
104
+ for tc in assistant_msg.tool_calls:
105
+ fn = tc.function
106
+ try:
107
+ kwargs = json.loads(fn.arguments) if fn.arguments else {}
108
+ except json.JSONDecodeError:
109
+ kwargs = {}
110
+
111
+ result = session.execute_tool(fn.name, **kwargs)
112
+ messages.append(
113
+ {
114
+ "role": "tool",
115
+ "tool_call_id": tc.id,
116
+ "content": json.dumps(result),
117
+ }
118
+ )
119
+
120
+ return messages
121
+
122
+
123
+ def run_agent_loop(
124
+ session: Any,
125
+ client: Any,
126
+ messages: list[dict],
127
+ model: str = "gpt-4o",
128
+ max_iterations: int = 10,
129
+ **create_kwargs: Any,
130
+ ) -> list[dict]:
131
+ """
132
+ Run a simple agentic loop that keeps calling the model until it stops
133
+ producing tool calls (or ``max_iterations`` is reached).
134
+
135
+ Parameters
136
+ ----------
137
+ session : MemorySession
138
+ client : openai.OpenAI
139
+ messages : list[dict] Initial message list (system + user turns).
140
+ model : str
141
+ max_iterations : int Safety cap to prevent infinite loops.
142
+ **create_kwargs Extra kwargs forwarded to ``client.chat.completions.create``.
143
+
144
+ Returns
145
+ -------
146
+ list[dict] Final message history.
147
+ """
148
+ from groundmemory.bootstrap.compaction import should_flush, get_compaction_prompts
149
+
150
+ tools = get_openai_tools()
151
+ compaction_cfg = session.config.compaction
152
+ _flushed = False # only flush once per loop
153
+
154
+ for _ in range(max_iterations):
155
+ response = client.chat.completions.create(
156
+ model=model,
157
+ messages=messages,
158
+ tools=tools,
159
+ **create_kwargs,
160
+ )
161
+ choice = response.choices[0]
162
+ messages = handle_tool_calls(session, response, messages)
163
+
164
+ # Check compaction threshold and inject a flush turn if needed
165
+ if not _flushed and compaction_cfg.enabled:
166
+ usage = response.usage
167
+ used = (usage.prompt_tokens or 0) + (usage.completion_tokens or 0)
168
+ if should_flush(used, compaction_cfg):
169
+ prompts = get_compaction_prompts(compaction_cfg)
170
+ messages.append({"role": "user", "content": prompts["user"]})
171
+ # One dedicated flush turn — let the model write to memory
172
+ flush_response = client.chat.completions.create(
173
+ model=model,
174
+ messages=[{"role": "system", "content": prompts["system"]}] + messages,
175
+ tools=tools,
176
+ **create_kwargs,
177
+ )
178
+ messages = handle_tool_calls(session, flush_response, messages)
179
+ _flushed = True
180
+
181
+ # Stop when the model is done calling tools
182
+ if choice.finish_reason != "tool_calls":
183
+ break
184
+
185
+ return messages
@@ -0,0 +1 @@
1
+ """Bootstrap package — system-prompt injection and compaction helpers."""
@@ -0,0 +1,77 @@
1
+ """
2
+ Compaction helpers — detect when the context window is nearly full and
3
+ provide prompts that instruct the agent to flush important information
4
+ to memory before the session is compacted.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from groundmemory.config import CompactionConfig
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Flush detection
12
+ # ---------------------------------------------------------------------------
13
+
14
+ _DEFAULT_SYSTEM_PROMPT = """\
15
+ You are about to run out of context window space. Before the session is \
16
+ compacted, you MUST save all important information to long-term memory using \
17
+ the memory_write tool. Focus on:
18
+
19
+ 1. Key facts, decisions, and outcomes from this session.
20
+ 2. Any new relationships between entities (use memory_relate).
21
+ 3. Updated user preferences or project state.
22
+ 4. Anything the user would want remembered in a future session.
23
+
24
+ Write concisely — prefer bullet points. Do NOT include information that is \
25
+ already recorded in previous memory entries unless it has changed.\
26
+ """
27
+
28
+ _DEFAULT_USER_PROMPT = """\
29
+ Please save a summary of our conversation so far to memory before we continue. \
30
+ Use memory_write to store the key points, and memory_relate for any entity \
31
+ relationships you have discovered.\
32
+ """
33
+
34
+
35
+ def should_flush(
36
+ current_tokens: int,
37
+ cfg: CompactionConfig,
38
+ context_window: int | None = None,
39
+ ) -> bool:
40
+ """
41
+ Return True when the agent should flush memory before compaction.
42
+
43
+ Parameters
44
+ ----------
45
+ current_tokens : int
46
+ Tokens *consumed* so far in the current context window (i.e. counted
47
+ from zero, not tokens remaining at the end).
48
+ cfg : CompactionConfig
49
+ context_window : int | None
50
+ Total token capacity of the model. Defaults to
51
+ ``cfg.context_window_tokens`` when not supplied.
52
+
53
+ The flush fires when token usage reaches the lower of two limits::
54
+
55
+ soft limit : cfg.soft_threshold_tokens
56
+ (absolute usage ceiling — fire no later than this)
57
+ hard limit : context_window - cfg.reserve_floor_tokens
58
+ (always keep this many tokens free for the model's reply)
59
+ """
60
+ if not cfg.enabled:
61
+ return False
62
+
63
+ window = context_window if context_window is not None else cfg.context_window_tokens
64
+ hard_limit = window - cfg.reserve_floor_tokens
65
+ return current_tokens >= min(cfg.soft_threshold_tokens, hard_limit)
66
+
67
+
68
+ def get_compaction_prompts(cfg: CompactionConfig) -> dict[str, str]:
69
+ """
70
+ Return ``{"system": str, "user": str}`` prompts for the pre-compaction flush.
71
+
72
+ Values come from the config when set, otherwise fall back to sensible defaults.
73
+ """
74
+ return {
75
+ "system": cfg.system_prompt or _DEFAULT_SYSTEM_PROMPT,
76
+ "user": cfg.user_prompt or _DEFAULT_USER_PROMPT,
77
+ }
@@ -0,0 +1,149 @@
1
+ """
2
+ Bootstrap injector — builds the system-prompt string that loads an agent's
3
+ long-term memory context at the start of a session.
4
+
5
+ Design goals
6
+ ------------
7
+ * Respect BootstrapConfig.max_chars_per_file and max_total_chars so
8
+ the injection never blows the context window.
9
+ * Warn (with a visible marker) when a file is truncated so the agent knows
10
+ it doesn't have the full picture.
11
+ * Optionally include the relation graph and daily logs.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+ from pathlib import Path
17
+ from typing import Sequence
18
+
19
+ from groundmemory.config import BootstrapConfig
20
+ from groundmemory.core.workspace import Workspace
21
+ from groundmemory.core import storage, relations as _graph
22
+ from groundmemory.core.index import MemoryIndex
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Internal helpers
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ def _read_capped(path: Path, max_chars: int) -> tuple[str, bool]:
31
+ """
32
+ Read *path* and return (text, truncated).
33
+
34
+ ``truncated`` is True when the file was longer than ``max_chars``.
35
+ """
36
+ if not path.exists():
37
+ return "", False
38
+ full = storage.read_file(path)
39
+ if len(full) <= max_chars:
40
+ return full, False
41
+ return full[:max_chars], True
42
+
43
+
44
+ def _section(title: str, body: str, truncated: bool = False) -> str:
45
+ """Wrap *body* in a labelled Markdown block."""
46
+ marker = " [TRUNCATED — use memory_get to read the rest]" if truncated else ""
47
+ return f"### {title}{marker}\n\n{body}\n"
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Public API
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def build_bootstrap_prompt(
56
+ workspace: Workspace,
57
+ cfg: BootstrapConfig,
58
+ index: MemoryIndex | None = None,
59
+ ) -> str:
60
+ """
61
+ Build the full memory-injection string for the system prompt.
62
+
63
+ Parameters
64
+ ----------
65
+ workspace : Workspace
66
+ cfg : BootstrapConfig (from groundmemoryConfig.bootstrap)
67
+ index : MemoryIndex | None
68
+ Only needed when ``cfg.inject_relations`` is True and you want
69
+ relation data from SQLite rather than only from RELATIONS.md.
70
+
71
+ Returns
72
+ -------
73
+ str
74
+ Ready-to-prepend system prompt block (may be empty string).
75
+ """
76
+ sections: list[str] = []
77
+ total_chars = 0
78
+
79
+ def _add(title: str, path: Path | None, body: str | None = None) -> bool:
80
+ """Add a section, respecting the total char budget. Returns False if budget exhausted."""
81
+ nonlocal total_chars
82
+ remaining = cfg.max_total_chars - total_chars
83
+ if remaining <= 0:
84
+ return False
85
+
86
+ if body is None:
87
+ if path is None or not path.exists():
88
+ return True
89
+ raw, truncated = _read_capped(path, min(cfg.max_chars_per_file, remaining))
90
+ if not raw.strip():
91
+ return True
92
+ body_text = raw
93
+ else:
94
+ truncated = len(body) > cfg.max_chars_per_file
95
+ body_text = body[: cfg.max_chars_per_file] if truncated else body
96
+ if len(body_text) > remaining:
97
+ body_text = body_text[:remaining]
98
+ truncated = True
99
+
100
+ if not body_text.strip():
101
+ return True
102
+
103
+ sections.append(_section(title, body_text, truncated))
104
+ total_chars += len(body_text)
105
+ return total_chars < cfg.max_total_chars
106
+
107
+ # 1. Long-term memory (MEMORY.md)
108
+ if cfg.inject_long_term_memory:
109
+ _add("Long-Term Memory", workspace.memory_file)
110
+
111
+ # 2. User profile (USER.md)
112
+ if cfg.inject_user_profile:
113
+ _add("User Profile", workspace.user_file)
114
+
115
+ # 3. Agent roster (AGENTS.md)
116
+ if cfg.inject_agents:
117
+ _add("Agent Roster", workspace.agents_file)
118
+
119
+ # 4. Relation graph
120
+ if cfg.inject_relations:
121
+ if index is not None:
122
+ relations = _graph.get_relations(index)
123
+ if relations:
124
+ rel_md = _graph.format_relations_for_context(relations)
125
+ _add("Relation Graph", None, body=rel_md)
126
+ else:
127
+ _add("Relation Graph", workspace.relations_file)
128
+
129
+ # 5. Daily logs: today + yesterday
130
+ if cfg.inject_daily_logs:
131
+ today = datetime.date.today()
132
+ yesterday = today - datetime.timedelta(days=1)
133
+ for day in (yesterday, today):
134
+ day_path = workspace.daily_file(day)
135
+ label = f"Daily Log ({day.isoformat()})"
136
+ if not _add(label, day_path):
137
+ break # budget exhausted
138
+
139
+ if not sections:
140
+ return ""
141
+
142
+ header = (
143
+ "<!-- groundmemory bootstrap start -->\n"
144
+ "## Your Memory Context\n\n"
145
+ "The following information was loaded from your long-term memory store. "
146
+ "Use it to maintain continuity across sessions.\n\n"
147
+ )
148
+ footer = "\n<!-- groundmemory bootstrap end -->"
149
+ return header + "\n".join(sections) + footer