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.
- groundmemory/__init__.py +30 -0
- groundmemory/adapters/__init__.py +1 -0
- groundmemory/adapters/anthropic.py +207 -0
- groundmemory/adapters/openai.py +185 -0
- groundmemory/bootstrap/__init__.py +1 -0
- groundmemory/bootstrap/compaction.py +77 -0
- groundmemory/bootstrap/injector.py +149 -0
- groundmemory/config/.env.example +121 -0
- groundmemory/config/__init__.py +428 -0
- groundmemory/config/groundmemory.yaml.example +172 -0
- groundmemory/core/__init__.py +22 -0
- groundmemory/core/chunker.py +193 -0
- groundmemory/core/embeddings.py +220 -0
- groundmemory/core/index.py +416 -0
- groundmemory/core/relations.py +409 -0
- groundmemory/core/search.py +273 -0
- groundmemory/core/storage.py +301 -0
- groundmemory/core/sync.py +216 -0
- groundmemory/core/workspace.py +220 -0
- groundmemory/mcp_server.py +366 -0
- groundmemory/session.py +213 -0
- groundmemory/tools/__init__.py +32 -0
- groundmemory/tools/base.py +83 -0
- groundmemory/tools/memory_delete.py +98 -0
- groundmemory/tools/memory_get.py +63 -0
- groundmemory/tools/memory_list.py +93 -0
- groundmemory/tools/memory_relate.py +118 -0
- groundmemory/tools/memory_replace.py +193 -0
- groundmemory/tools/memory_search.py +69 -0
- groundmemory/tools/memory_write.py +99 -0
- groundmemory-0.2.4.dist-info/METADATA +250 -0
- groundmemory-0.2.4.dist-info/RECORD +35 -0
- groundmemory-0.2.4.dist-info/WHEEL +4 -0
- groundmemory-0.2.4.dist-info/entry_points.txt +2 -0
- groundmemory-0.2.4.dist-info/licenses/LICENSE +7 -0
groundmemory/__init__.py
ADDED
|
@@ -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
|