harness-memory 0.1.1__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.
- harness_memory/__init__.py +27 -0
- harness_memory/_version.py +24 -0
- harness_memory/backends/__init__.py +40 -0
- harness_memory/backends/postgres.py +347 -0
- harness_memory/backends/sqlite.py +339 -0
- harness_memory/core.py +154 -0
- harness_memory/types.py +76 -0
- harness_memory-0.1.1.dist-info/METADATA +160 -0
- harness_memory-0.1.1.dist-info/RECORD +11 -0
- harness_memory-0.1.1.dist-info/WHEEL +4 -0
- harness_memory-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""orcakit-harness-memory: pluggable memory system for LLM agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from harness_memory.core import Memory
|
|
6
|
+
from harness_memory.types import (
|
|
7
|
+
ConversationRecord,
|
|
8
|
+
ConversationSummary,
|
|
9
|
+
MemoryNode,
|
|
10
|
+
Message,
|
|
11
|
+
SearchResult,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from harness_memory._version import __version__
|
|
16
|
+
except ImportError:
|
|
17
|
+
__version__ = "0.0.0+unknown"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ConversationRecord",
|
|
21
|
+
"ConversationSummary",
|
|
22
|
+
"Memory",
|
|
23
|
+
"MemoryNode",
|
|
24
|
+
"Message",
|
|
25
|
+
"SearchResult",
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Memory backend protocol and registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from harness_memory.types import (
|
|
8
|
+
ConversationRecord,
|
|
9
|
+
ConversationSummary,
|
|
10
|
+
MemoryNode,
|
|
11
|
+
SearchResult,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class MemoryBackend(Protocol):
|
|
17
|
+
"""Abstract interface for memory storage backends.
|
|
18
|
+
|
|
19
|
+
Implementations: SqliteMemoryBackend, PostgresMemoryBackend, QdrantMemoryBackend.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def save_node(self, node: MemoryNode) -> None: ...
|
|
23
|
+
def get_children(self, parent_id: str | None) -> list[MemoryNode]: ...
|
|
24
|
+
def update_node(self, node_id: str, content: str, topic: str | None = None) -> None: ...
|
|
25
|
+
def search_memories(self, query: str, limit: int = 5) -> list[MemoryNode]: ...
|
|
26
|
+
def get_tree(self) -> list[MemoryNode]: ...
|
|
27
|
+
def save_conversation(self, record: ConversationRecord) -> None: ...
|
|
28
|
+
def search_messages(self, query: str, limit: int = 10) -> list[SearchResult]: ...
|
|
29
|
+
def list_conversations(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
user: str | None = None,
|
|
33
|
+
after: Any | None = None,
|
|
34
|
+
before: Any | None = None,
|
|
35
|
+
limit: int = 20,
|
|
36
|
+
) -> list[ConversationSummary]: ...
|
|
37
|
+
def get_conversation(self, conversation_id: str) -> ConversationRecord | None: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["MemoryBackend"]
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""PostgreSQL-based memory backend with tsvector full-text search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import psycopg
|
|
11
|
+
from psycopg.rows import dict_row
|
|
12
|
+
|
|
13
|
+
from harness_memory.types import (
|
|
14
|
+
ConversationRecord,
|
|
15
|
+
ConversationSummary,
|
|
16
|
+
MemoryNode,
|
|
17
|
+
Message,
|
|
18
|
+
SearchResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PostgresMemoryBackend:
|
|
23
|
+
"""Memory backend using PostgreSQL + tsvector/GIN full-text search.
|
|
24
|
+
|
|
25
|
+
Multi-agent isolation via PostgreSQL schemas (one schema per namespace).
|
|
26
|
+
Default DSN: ``postgresql://localhost/harness_memory``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
namespace: str,
|
|
32
|
+
dsn: str = "postgresql://localhost/harness_memory",
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._ns = re.sub(r"[^a-z0-9_]", "_", namespace.lower())
|
|
36
|
+
self._dsn = dsn
|
|
37
|
+
self._conn = psycopg.connect(dsn, row_factory=dict_row, **kwargs)
|
|
38
|
+
self._init_schema()
|
|
39
|
+
|
|
40
|
+
def close(self) -> None:
|
|
41
|
+
"""Close the database connection."""
|
|
42
|
+
self._conn.close()
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Schema
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _init_schema(self) -> None:
|
|
49
|
+
ns = self._ns
|
|
50
|
+
with self._conn.cursor() as cur:
|
|
51
|
+
cur.execute(f"CREATE SCHEMA IF NOT EXISTS {ns}")
|
|
52
|
+
|
|
53
|
+
cur.execute(f"""
|
|
54
|
+
CREATE TABLE IF NOT EXISTS {ns}.memory_nodes (
|
|
55
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
56
|
+
parent_id UUID REFERENCES {ns}.memory_nodes(id) ON DELETE CASCADE,
|
|
57
|
+
level TEXT NOT NULL CHECK (level IN ('root', 'branch', 'leaf')),
|
|
58
|
+
content TEXT NOT NULL,
|
|
59
|
+
topic TEXT,
|
|
60
|
+
conversation_id UUID,
|
|
61
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
62
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
63
|
+
content_tsv TSVECTOR GENERATED ALWAYS AS (
|
|
64
|
+
to_tsvector('simple', content) || to_tsvector('simple', COALESCE(topic, ''))
|
|
65
|
+
) STORED
|
|
66
|
+
)
|
|
67
|
+
""")
|
|
68
|
+
cur.execute(
|
|
69
|
+
f"CREATE INDEX IF NOT EXISTS memory_nodes_parent ON {ns}.memory_nodes(parent_id)"
|
|
70
|
+
)
|
|
71
|
+
cur.execute(
|
|
72
|
+
f"CREATE INDEX IF NOT EXISTS memory_nodes_level ON {ns}.memory_nodes(level)"
|
|
73
|
+
)
|
|
74
|
+
cur.execute(
|
|
75
|
+
f"CREATE INDEX IF NOT EXISTS memory_nodes_fts ON {ns}.memory_nodes USING GIN(content_tsv)"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
cur.execute(f"""
|
|
79
|
+
CREATE TABLE IF NOT EXISTS {ns}.conversations (
|
|
80
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
81
|
+
thread_id TEXT NOT NULL,
|
|
82
|
+
"user" TEXT,
|
|
83
|
+
started_at TIMESTAMPTZ NOT NULL,
|
|
84
|
+
ended_at TIMESTAMPTZ NOT NULL,
|
|
85
|
+
summary TEXT,
|
|
86
|
+
metadata JSONB,
|
|
87
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
88
|
+
)
|
|
89
|
+
""")
|
|
90
|
+
cur.execute(
|
|
91
|
+
f"CREATE INDEX IF NOT EXISTS conversations_thread ON {ns}.conversations(thread_id)"
|
|
92
|
+
)
|
|
93
|
+
cur.execute(
|
|
94
|
+
f'CREATE INDEX IF NOT EXISTS conversations_user ON {ns}.conversations("user")'
|
|
95
|
+
)
|
|
96
|
+
cur.execute(
|
|
97
|
+
f"CREATE INDEX IF NOT EXISTS conversations_time ON {ns}.conversations(started_at)"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
cur.execute(f"""
|
|
101
|
+
CREATE TABLE IF NOT EXISTS {ns}.messages (
|
|
102
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
103
|
+
conversation_id UUID NOT NULL REFERENCES {ns}.conversations(id) ON DELETE CASCADE,
|
|
104
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool')),
|
|
105
|
+
content TEXT NOT NULL,
|
|
106
|
+
timestamp TIMESTAMPTZ NOT NULL,
|
|
107
|
+
tool_name TEXT,
|
|
108
|
+
seq INTEGER NOT NULL,
|
|
109
|
+
content_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED
|
|
110
|
+
)
|
|
111
|
+
""")
|
|
112
|
+
cur.execute(
|
|
113
|
+
f"CREATE INDEX IF NOT EXISTS messages_conv ON {ns}.messages(conversation_id)"
|
|
114
|
+
)
|
|
115
|
+
cur.execute(
|
|
116
|
+
f"CREATE INDEX IF NOT EXISTS messages_fts ON {ns}.messages USING GIN(content_tsv)"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self._conn.commit()
|
|
120
|
+
# Set search_path so subsequent queries don't need schema prefix
|
|
121
|
+
self._conn.execute(f"SET search_path TO {ns}, public")
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Memory tree
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def save_node(self, node: MemoryNode) -> None:
|
|
128
|
+
with self._conn.cursor() as cur:
|
|
129
|
+
cur.execute(
|
|
130
|
+
"""INSERT INTO memory_nodes
|
|
131
|
+
(id, parent_id, level, content, topic, conversation_id, created_at, updated_at)
|
|
132
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
133
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
134
|
+
parent_id = EXCLUDED.parent_id,
|
|
135
|
+
level = EXCLUDED.level,
|
|
136
|
+
content = EXCLUDED.content,
|
|
137
|
+
topic = EXCLUDED.topic,
|
|
138
|
+
conversation_id = EXCLUDED.conversation_id,
|
|
139
|
+
updated_at = EXCLUDED.updated_at
|
|
140
|
+
""",
|
|
141
|
+
(
|
|
142
|
+
node.id,
|
|
143
|
+
node.parent_id,
|
|
144
|
+
node.level,
|
|
145
|
+
node.content,
|
|
146
|
+
node.topic,
|
|
147
|
+
node.conversation_id,
|
|
148
|
+
node.created_at,
|
|
149
|
+
node.updated_at,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
self._conn.commit()
|
|
153
|
+
|
|
154
|
+
def get_children(self, parent_id: str | None) -> list[MemoryNode]:
|
|
155
|
+
with self._conn.cursor() as cur:
|
|
156
|
+
if parent_id is None:
|
|
157
|
+
cur.execute("SELECT * FROM memory_nodes WHERE parent_id IS NULL")
|
|
158
|
+
else:
|
|
159
|
+
cur.execute("SELECT * FROM memory_nodes WHERE parent_id = %s", (parent_id,))
|
|
160
|
+
rows = cur.fetchall()
|
|
161
|
+
return [self._row_to_node(r) for r in rows]
|
|
162
|
+
|
|
163
|
+
def update_node(self, node_id: str, content: str, topic: str | None = None) -> None:
|
|
164
|
+
now = datetime.now(UTC)
|
|
165
|
+
with self._conn.cursor() as cur:
|
|
166
|
+
cur.execute(
|
|
167
|
+
"UPDATE memory_nodes SET content = %s, topic = %s, updated_at = %s WHERE id = %s",
|
|
168
|
+
(content, topic, now, node_id),
|
|
169
|
+
)
|
|
170
|
+
self._conn.commit()
|
|
171
|
+
|
|
172
|
+
def search_memories(self, query: str, limit: int = 5) -> list[MemoryNode]:
|
|
173
|
+
with self._conn.cursor() as cur:
|
|
174
|
+
cur.execute(
|
|
175
|
+
"""SELECT * FROM memory_nodes
|
|
176
|
+
WHERE content_tsv @@ plainto_tsquery('simple', %s)
|
|
177
|
+
ORDER BY ts_rank(content_tsv, plainto_tsquery('simple', %s)) DESC
|
|
178
|
+
LIMIT %s""",
|
|
179
|
+
(query, query, limit),
|
|
180
|
+
)
|
|
181
|
+
rows = cur.fetchall()
|
|
182
|
+
return [self._row_to_node(r) for r in rows]
|
|
183
|
+
|
|
184
|
+
def get_tree(self) -> list[MemoryNode]:
|
|
185
|
+
with self._conn.cursor() as cur:
|
|
186
|
+
cur.execute("SELECT * FROM memory_nodes")
|
|
187
|
+
rows = cur.fetchall()
|
|
188
|
+
return [self._row_to_node(r) for r in rows]
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# Conversations
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def save_conversation(self, record: ConversationRecord) -> None:
|
|
195
|
+
metadata_json = json.dumps(record.metadata, ensure_ascii=False) if record.metadata else None
|
|
196
|
+
with self._conn.cursor() as cur:
|
|
197
|
+
cur.execute(
|
|
198
|
+
"""INSERT INTO conversations
|
|
199
|
+
(id, thread_id, "user", started_at, ended_at, summary, metadata)
|
|
200
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
201
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
202
|
+
thread_id = EXCLUDED.thread_id,
|
|
203
|
+
"user" = EXCLUDED."user",
|
|
204
|
+
started_at = EXCLUDED.started_at,
|
|
205
|
+
ended_at = EXCLUDED.ended_at,
|
|
206
|
+
summary = EXCLUDED.summary,
|
|
207
|
+
metadata = EXCLUDED.metadata
|
|
208
|
+
""",
|
|
209
|
+
(
|
|
210
|
+
record.id,
|
|
211
|
+
record.thread_id,
|
|
212
|
+
record.user,
|
|
213
|
+
record.started_at,
|
|
214
|
+
record.ended_at,
|
|
215
|
+
record.summary,
|
|
216
|
+
metadata_json,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
# Delete old messages on upsert, then re-insert
|
|
220
|
+
cur.execute("DELETE FROM messages WHERE conversation_id = %s", (record.id,))
|
|
221
|
+
for seq, msg in enumerate(record.messages):
|
|
222
|
+
cur.execute(
|
|
223
|
+
"""INSERT INTO messages
|
|
224
|
+
(conversation_id, role, content, timestamp, tool_name, seq)
|
|
225
|
+
VALUES (%s, %s, %s, %s, %s, %s)""",
|
|
226
|
+
(record.id, msg.role, msg.content, msg.timestamp, msg.tool_name, seq),
|
|
227
|
+
)
|
|
228
|
+
self._conn.commit()
|
|
229
|
+
|
|
230
|
+
def search_messages(self, query: str, limit: int = 10) -> list[SearchResult]:
|
|
231
|
+
with self._conn.cursor() as cur:
|
|
232
|
+
cur.execute(
|
|
233
|
+
"""SELECT m.conversation_id, m.content, m.role, m.timestamp,
|
|
234
|
+
ts_rank(m.content_tsv, plainto_tsquery('simple', %s)) AS score
|
|
235
|
+
FROM messages m
|
|
236
|
+
WHERE m.content_tsv @@ plainto_tsquery('simple', %s)
|
|
237
|
+
ORDER BY score DESC
|
|
238
|
+
LIMIT %s""",
|
|
239
|
+
(query, query, limit),
|
|
240
|
+
)
|
|
241
|
+
rows = cur.fetchall()
|
|
242
|
+
return [
|
|
243
|
+
SearchResult(
|
|
244
|
+
conversation_id=str(r["conversation_id"]),
|
|
245
|
+
message_content=r["content"],
|
|
246
|
+
role=r["role"],
|
|
247
|
+
timestamp=r["timestamp"],
|
|
248
|
+
score=float(r["score"]),
|
|
249
|
+
)
|
|
250
|
+
for r in rows
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
def list_conversations(
|
|
254
|
+
self,
|
|
255
|
+
*,
|
|
256
|
+
user: str | None = None,
|
|
257
|
+
after: Any | None = None,
|
|
258
|
+
before: Any | None = None,
|
|
259
|
+
limit: int = 20,
|
|
260
|
+
) -> list[ConversationSummary]:
|
|
261
|
+
conditions: list[str] = []
|
|
262
|
+
params: list[Any] = []
|
|
263
|
+
if user is not None:
|
|
264
|
+
conditions.append('"user" = %s')
|
|
265
|
+
params.append(user)
|
|
266
|
+
if after is not None:
|
|
267
|
+
conditions.append("started_at >= %s")
|
|
268
|
+
params.append(after if isinstance(after, datetime) else str(after))
|
|
269
|
+
if before is not None:
|
|
270
|
+
conditions.append("started_at <= %s")
|
|
271
|
+
params.append(before if isinstance(before, datetime) else str(before))
|
|
272
|
+
|
|
273
|
+
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
274
|
+
params.append(limit)
|
|
275
|
+
|
|
276
|
+
with self._conn.cursor() as cur:
|
|
277
|
+
cur.execute(
|
|
278
|
+
f'SELECT id, thread_id, "user", started_at, summary'
|
|
279
|
+
f" FROM conversations {where} ORDER BY started_at DESC LIMIT %s",
|
|
280
|
+
params,
|
|
281
|
+
)
|
|
282
|
+
rows = cur.fetchall()
|
|
283
|
+
return [
|
|
284
|
+
ConversationSummary(
|
|
285
|
+
id=str(r["id"]),
|
|
286
|
+
thread_id=r["thread_id"],
|
|
287
|
+
user=r["user"],
|
|
288
|
+
started_at=r["started_at"],
|
|
289
|
+
summary=r["summary"],
|
|
290
|
+
)
|
|
291
|
+
for r in rows
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
def get_conversation(self, conversation_id: str) -> ConversationRecord | None:
|
|
295
|
+
with self._conn.cursor() as cur:
|
|
296
|
+
cur.execute("SELECT * FROM conversations WHERE id = %s", (conversation_id,))
|
|
297
|
+
row = cur.fetchone()
|
|
298
|
+
if row is None:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
cur.execute(
|
|
302
|
+
"SELECT * FROM messages WHERE conversation_id = %s ORDER BY seq",
|
|
303
|
+
(conversation_id,),
|
|
304
|
+
)
|
|
305
|
+
msg_rows = cur.fetchall()
|
|
306
|
+
|
|
307
|
+
messages = [
|
|
308
|
+
Message(
|
|
309
|
+
role=m["role"],
|
|
310
|
+
content=m["content"],
|
|
311
|
+
timestamp=m["timestamp"],
|
|
312
|
+
tool_name=m["tool_name"],
|
|
313
|
+
)
|
|
314
|
+
for m in msg_rows
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
metadata = row["metadata"] if row["metadata"] else {}
|
|
318
|
+
if isinstance(metadata, str):
|
|
319
|
+
metadata = json.loads(metadata)
|
|
320
|
+
|
|
321
|
+
return ConversationRecord(
|
|
322
|
+
id=str(row["id"]),
|
|
323
|
+
thread_id=row["thread_id"],
|
|
324
|
+
user=row["user"],
|
|
325
|
+
started_at=row["started_at"],
|
|
326
|
+
ended_at=row["ended_at"],
|
|
327
|
+
messages=messages,
|
|
328
|
+
summary=row["summary"],
|
|
329
|
+
metadata=metadata,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
# Helpers
|
|
334
|
+
# ------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _row_to_node(row: dict[str, Any]) -> MemoryNode:
|
|
338
|
+
return MemoryNode(
|
|
339
|
+
id=str(row["id"]),
|
|
340
|
+
parent_id=str(row["parent_id"]) if row["parent_id"] else None,
|
|
341
|
+
level=row["level"],
|
|
342
|
+
content=row["content"],
|
|
343
|
+
topic=row["topic"],
|
|
344
|
+
conversation_id=str(row["conversation_id"]) if row["conversation_id"] else None,
|
|
345
|
+
created_at=row["created_at"],
|
|
346
|
+
updated_at=row["updated_at"],
|
|
347
|
+
)
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""SQLite-based memory backend with FTS5 full-text search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from harness_memory.types import (
|
|
12
|
+
ConversationRecord,
|
|
13
|
+
ConversationSummary,
|
|
14
|
+
MemoryNode,
|
|
15
|
+
Message,
|
|
16
|
+
SearchResult,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SqliteMemoryBackend:
|
|
21
|
+
"""Memory backend using SQLite + FTS5.
|
|
22
|
+
|
|
23
|
+
Multi-agent isolation via table-name prefix (namespace).
|
|
24
|
+
Default database path: ``~/.harness-memory/session.sqlite``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
namespace: str,
|
|
30
|
+
db_path: str | Path = "~/.harness-memory/session.sqlite",
|
|
31
|
+
) -> None:
|
|
32
|
+
self._ns = namespace.replace("-", "_").replace(".", "_")
|
|
33
|
+
self._db_path = Path(db_path).expanduser()
|
|
34
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
|
36
|
+
self._conn.row_factory = sqlite3.Row
|
|
37
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
38
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
39
|
+
self._init_schema()
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
self._conn.close()
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Schema
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _init_schema(self) -> None:
|
|
49
|
+
ns = self._ns
|
|
50
|
+
self._conn.executescript(f"""
|
|
51
|
+
CREATE TABLE IF NOT EXISTS {ns}_memory_nodes (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
parent_id TEXT,
|
|
54
|
+
level TEXT NOT NULL CHECK (level IN ('root', 'branch', 'leaf')),
|
|
55
|
+
content TEXT NOT NULL,
|
|
56
|
+
topic TEXT,
|
|
57
|
+
conversation_id TEXT,
|
|
58
|
+
created_at TEXT NOT NULL,
|
|
59
|
+
updated_at TEXT NOT NULL
|
|
60
|
+
);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS {ns}_memory_nodes_parent
|
|
62
|
+
ON {ns}_memory_nodes(parent_id);
|
|
63
|
+
CREATE INDEX IF NOT EXISTS {ns}_memory_nodes_level
|
|
64
|
+
ON {ns}_memory_nodes(level);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS {ns}_conversations (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
thread_id TEXT NOT NULL,
|
|
69
|
+
user TEXT,
|
|
70
|
+
started_at TEXT NOT NULL,
|
|
71
|
+
ended_at TEXT NOT NULL,
|
|
72
|
+
summary TEXT,
|
|
73
|
+
metadata TEXT,
|
|
74
|
+
created_at TEXT NOT NULL
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS {ns}_conversations_thread
|
|
77
|
+
ON {ns}_conversations(thread_id);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS {ns}_conversations_user
|
|
79
|
+
ON {ns}_conversations(user);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS {ns}_conversations_time
|
|
81
|
+
ON {ns}_conversations(started_at);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS {ns}_messages (
|
|
84
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
85
|
+
conversation_id TEXT NOT NULL,
|
|
86
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool')),
|
|
87
|
+
content TEXT NOT NULL,
|
|
88
|
+
timestamp TEXT NOT NULL,
|
|
89
|
+
tool_name TEXT,
|
|
90
|
+
seq INTEGER NOT NULL
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS {ns}_messages_conv
|
|
93
|
+
ON {ns}_messages(conversation_id);
|
|
94
|
+
""")
|
|
95
|
+
|
|
96
|
+
# FTS5 tables (CREATE VIRTUAL TABLE IF NOT EXISTS is supported)
|
|
97
|
+
self._conn.executescript(f"""
|
|
98
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS {ns}_messages_fts USING fts5(
|
|
99
|
+
content,
|
|
100
|
+
content='{ns}_messages',
|
|
101
|
+
content_rowid='id',
|
|
102
|
+
tokenize='unicode61'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS {ns}_memory_fts USING fts5(
|
|
106
|
+
content,
|
|
107
|
+
topic,
|
|
108
|
+
content='{ns}_memory_nodes',
|
|
109
|
+
content_rowid='rowid',
|
|
110
|
+
tokenize='unicode61'
|
|
111
|
+
);
|
|
112
|
+
""")
|
|
113
|
+
|
|
114
|
+
# Triggers for FTS sync
|
|
115
|
+
self._conn.executescript(f"""
|
|
116
|
+
CREATE TRIGGER IF NOT EXISTS {ns}_messages_ai
|
|
117
|
+
AFTER INSERT ON {ns}_messages BEGIN
|
|
118
|
+
INSERT INTO {ns}_messages_fts(rowid, content)
|
|
119
|
+
VALUES (new.id, new.content);
|
|
120
|
+
END;
|
|
121
|
+
|
|
122
|
+
CREATE TRIGGER IF NOT EXISTS {ns}_memory_ai
|
|
123
|
+
AFTER INSERT ON {ns}_memory_nodes BEGIN
|
|
124
|
+
INSERT INTO {ns}_memory_fts(rowid, content, topic)
|
|
125
|
+
VALUES (new.rowid, new.content, COALESCE(new.topic, ''));
|
|
126
|
+
END;
|
|
127
|
+
|
|
128
|
+
CREATE TRIGGER IF NOT EXISTS {ns}_memory_au
|
|
129
|
+
AFTER UPDATE ON {ns}_memory_nodes BEGIN
|
|
130
|
+
INSERT INTO {ns}_memory_fts({ns}_memory_fts, rowid, content, topic)
|
|
131
|
+
VALUES ('delete', old.rowid, old.content, COALESCE(old.topic, ''));
|
|
132
|
+
INSERT INTO {ns}_memory_fts(rowid, content, topic)
|
|
133
|
+
VALUES (new.rowid, new.content, COALESCE(new.topic, ''));
|
|
134
|
+
END;
|
|
135
|
+
""")
|
|
136
|
+
self._conn.commit()
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
# Memory tree
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def save_node(self, node: MemoryNode) -> None:
|
|
143
|
+
ns = self._ns
|
|
144
|
+
self._conn.execute(
|
|
145
|
+
f"""INSERT OR REPLACE INTO {ns}_memory_nodes
|
|
146
|
+
(id, parent_id, level, content, topic, conversation_id, created_at, updated_at)
|
|
147
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
148
|
+
(
|
|
149
|
+
node.id,
|
|
150
|
+
node.parent_id,
|
|
151
|
+
node.level,
|
|
152
|
+
node.content,
|
|
153
|
+
node.topic,
|
|
154
|
+
node.conversation_id,
|
|
155
|
+
node.created_at.isoformat(),
|
|
156
|
+
node.updated_at.isoformat(),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
self._conn.commit()
|
|
160
|
+
|
|
161
|
+
def get_children(self, parent_id: str | None) -> list[MemoryNode]:
|
|
162
|
+
ns = self._ns
|
|
163
|
+
if parent_id is None:
|
|
164
|
+
rows = self._conn.execute(f"SELECT * FROM {ns}_memory_nodes WHERE parent_id IS NULL").fetchall()
|
|
165
|
+
else:
|
|
166
|
+
rows = self._conn.execute(f"SELECT * FROM {ns}_memory_nodes WHERE parent_id = ?", (parent_id,)).fetchall()
|
|
167
|
+
return [self._row_to_node(r) for r in rows]
|
|
168
|
+
|
|
169
|
+
def update_node(self, node_id: str, content: str, topic: str | None = None) -> None:
|
|
170
|
+
ns = self._ns
|
|
171
|
+
now = datetime.now(UTC).isoformat()
|
|
172
|
+
self._conn.execute(
|
|
173
|
+
f"UPDATE {ns}_memory_nodes SET content = ?, topic = ?, updated_at = ? WHERE id = ?",
|
|
174
|
+
(content, topic, now, node_id),
|
|
175
|
+
)
|
|
176
|
+
self._conn.commit()
|
|
177
|
+
|
|
178
|
+
def search_memories(self, query: str, limit: int = 5) -> list[MemoryNode]:
|
|
179
|
+
ns = self._ns
|
|
180
|
+
# Wrap in double-quotes so FTS5 treats the whole string as a phrase
|
|
181
|
+
# rather than interpreting operators like '-' as NOT.
|
|
182
|
+
safe_query = '"{}"'.format(query.replace('"', '""'))
|
|
183
|
+
rows = self._conn.execute(
|
|
184
|
+
f"""SELECT n.* FROM {ns}_memory_fts f
|
|
185
|
+
JOIN {ns}_memory_nodes n ON f.rowid = n.rowid
|
|
186
|
+
WHERE f.{ns}_memory_fts MATCH ?
|
|
187
|
+
ORDER BY rank
|
|
188
|
+
LIMIT ?""",
|
|
189
|
+
(safe_query, limit),
|
|
190
|
+
).fetchall()
|
|
191
|
+
return [self._row_to_node(r) for r in rows]
|
|
192
|
+
|
|
193
|
+
def get_tree(self) -> list[MemoryNode]:
|
|
194
|
+
ns = self._ns
|
|
195
|
+
rows = self._conn.execute(f"SELECT * FROM {ns}_memory_nodes").fetchall()
|
|
196
|
+
return [self._row_to_node(r) for r in rows]
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Conversations
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def save_conversation(self, record: ConversationRecord) -> None:
|
|
203
|
+
ns = self._ns
|
|
204
|
+
now = datetime.now(UTC).isoformat()
|
|
205
|
+
self._conn.execute(
|
|
206
|
+
f"""INSERT OR REPLACE INTO {ns}_conversations
|
|
207
|
+
(id, thread_id, user, started_at, ended_at, summary, metadata, created_at)
|
|
208
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
209
|
+
(
|
|
210
|
+
record.id,
|
|
211
|
+
record.thread_id,
|
|
212
|
+
record.user,
|
|
213
|
+
record.started_at.isoformat(),
|
|
214
|
+
record.ended_at.isoformat(),
|
|
215
|
+
record.summary,
|
|
216
|
+
json.dumps(record.metadata, ensure_ascii=False),
|
|
217
|
+
now,
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
for seq, msg in enumerate(record.messages):
|
|
221
|
+
self._conn.execute(
|
|
222
|
+
f"""INSERT INTO {ns}_messages
|
|
223
|
+
(conversation_id, role, content, timestamp, tool_name, seq)
|
|
224
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
225
|
+
(record.id, msg.role, msg.content, msg.timestamp.isoformat(), msg.tool_name, seq),
|
|
226
|
+
)
|
|
227
|
+
self._conn.commit()
|
|
228
|
+
|
|
229
|
+
def search_messages(self, query: str, limit: int = 10) -> list[SearchResult]:
|
|
230
|
+
ns = self._ns
|
|
231
|
+
rows = self._conn.execute(
|
|
232
|
+
f"""SELECT m.conversation_id, m.content, m.role, m.timestamp,
|
|
233
|
+
rank AS score
|
|
234
|
+
FROM {ns}_messages_fts f
|
|
235
|
+
JOIN {ns}_messages m ON f.rowid = m.id
|
|
236
|
+
WHERE f.{ns}_messages_fts MATCH ?
|
|
237
|
+
ORDER BY rank
|
|
238
|
+
LIMIT ?""",
|
|
239
|
+
(query, limit),
|
|
240
|
+
).fetchall()
|
|
241
|
+
return [
|
|
242
|
+
SearchResult(
|
|
243
|
+
conversation_id=r["conversation_id"],
|
|
244
|
+
message_content=r["content"],
|
|
245
|
+
role=r["role"],
|
|
246
|
+
timestamp=datetime.fromisoformat(r["timestamp"]),
|
|
247
|
+
score=float(r["score"]),
|
|
248
|
+
)
|
|
249
|
+
for r in rows
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
def list_conversations(
|
|
253
|
+
self,
|
|
254
|
+
*,
|
|
255
|
+
user: str | None = None,
|
|
256
|
+
after: Any | None = None,
|
|
257
|
+
before: Any | None = None,
|
|
258
|
+
limit: int = 20,
|
|
259
|
+
) -> list[ConversationSummary]:
|
|
260
|
+
ns = self._ns
|
|
261
|
+
conditions: list[str] = []
|
|
262
|
+
params: list[Any] = []
|
|
263
|
+
if user is not None:
|
|
264
|
+
conditions.append("user = ?")
|
|
265
|
+
params.append(user)
|
|
266
|
+
if after is not None:
|
|
267
|
+
conditions.append("started_at >= ?")
|
|
268
|
+
params.append(after.isoformat() if isinstance(after, datetime) else str(after))
|
|
269
|
+
if before is not None:
|
|
270
|
+
conditions.append("started_at <= ?")
|
|
271
|
+
params.append(before.isoformat() if isinstance(before, datetime) else str(before))
|
|
272
|
+
|
|
273
|
+
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
274
|
+
params.append(limit)
|
|
275
|
+
|
|
276
|
+
rows = self._conn.execute(
|
|
277
|
+
f"SELECT id, thread_id, user, started_at, summary"
|
|
278
|
+
f" FROM {ns}_conversations {where} ORDER BY started_at DESC LIMIT ?",
|
|
279
|
+
params,
|
|
280
|
+
).fetchall()
|
|
281
|
+
return [
|
|
282
|
+
ConversationSummary(
|
|
283
|
+
id=r["id"],
|
|
284
|
+
thread_id=r["thread_id"],
|
|
285
|
+
user=r["user"],
|
|
286
|
+
started_at=datetime.fromisoformat(r["started_at"]),
|
|
287
|
+
summary=r["summary"],
|
|
288
|
+
)
|
|
289
|
+
for r in rows
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
def get_conversation(self, conversation_id: str) -> ConversationRecord | None:
|
|
293
|
+
ns = self._ns
|
|
294
|
+
row = self._conn.execute(f"SELECT * FROM {ns}_conversations WHERE id = ?", (conversation_id,)).fetchone()
|
|
295
|
+
if row is None:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
msg_rows = self._conn.execute(
|
|
299
|
+
f"SELECT * FROM {ns}_messages WHERE conversation_id = ? ORDER BY seq",
|
|
300
|
+
(conversation_id,),
|
|
301
|
+
).fetchall()
|
|
302
|
+
|
|
303
|
+
messages = [
|
|
304
|
+
Message(
|
|
305
|
+
role=m["role"],
|
|
306
|
+
content=m["content"],
|
|
307
|
+
timestamp=datetime.fromisoformat(m["timestamp"]),
|
|
308
|
+
tool_name=m["tool_name"],
|
|
309
|
+
)
|
|
310
|
+
for m in msg_rows
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
return ConversationRecord(
|
|
314
|
+
id=row["id"],
|
|
315
|
+
thread_id=row["thread_id"],
|
|
316
|
+
user=row["user"],
|
|
317
|
+
started_at=datetime.fromisoformat(row["started_at"]),
|
|
318
|
+
ended_at=datetime.fromisoformat(row["ended_at"]),
|
|
319
|
+
messages=messages,
|
|
320
|
+
summary=row["summary"],
|
|
321
|
+
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# ------------------------------------------------------------------
|
|
325
|
+
# Helpers
|
|
326
|
+
# ------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _row_to_node(row: sqlite3.Row) -> MemoryNode:
|
|
330
|
+
return MemoryNode(
|
|
331
|
+
id=row["id"],
|
|
332
|
+
parent_id=row["parent_id"],
|
|
333
|
+
level=row["level"],
|
|
334
|
+
content=row["content"],
|
|
335
|
+
topic=row["topic"],
|
|
336
|
+
conversation_id=row["conversation_id"],
|
|
337
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
338
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
339
|
+
)
|
harness_memory/core.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Memory — the unified interface for the memory system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from harness_memory.backends import MemoryBackend
|
|
10
|
+
from harness_memory.types import (
|
|
11
|
+
ConversationRecord,
|
|
12
|
+
ConversationSummary,
|
|
13
|
+
MemoryNode,
|
|
14
|
+
SearchResult,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Memory:
|
|
19
|
+
"""High-level memory interface.
|
|
20
|
+
|
|
21
|
+
Wraps a ``MemoryBackend`` and provides the developer-facing API for
|
|
22
|
+
storing/recalling memories and managing conversation records.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
namespace: Isolation namespace (typically agent name).
|
|
26
|
+
backend: Either a backend type string (``"sqlite"``/``"postgres"``/
|
|
27
|
+
``"qdrant"``) that will be resolved via factory, or a pre-built
|
|
28
|
+
``MemoryBackend`` instance for direct injection.
|
|
29
|
+
backend_config: Backend-specific configuration dict. Only used when
|
|
30
|
+
``backend`` is a string. Ignored when ``backend`` is an instance.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
namespace: str,
|
|
36
|
+
backend: str | MemoryBackend = "sqlite",
|
|
37
|
+
backend_config: dict[str, Any] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._namespace = namespace
|
|
40
|
+
if isinstance(backend, str):
|
|
41
|
+
self._backend = _resolve_memory_backend(backend, namespace, backend_config or {})
|
|
42
|
+
else:
|
|
43
|
+
self._backend = backend
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def backend(self) -> MemoryBackend:
|
|
47
|
+
"""The underlying storage backend."""
|
|
48
|
+
return self._backend
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def namespace(self) -> str:
|
|
52
|
+
"""The isolation namespace."""
|
|
53
|
+
return self._namespace
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Memory management
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def store(self, content: str, *, topic: str | None = None) -> MemoryNode:
|
|
60
|
+
"""Store a memory as a leaf node in the tree."""
|
|
61
|
+
node = MemoryNode(
|
|
62
|
+
id=uuid.uuid4().hex,
|
|
63
|
+
parent_id=None,
|
|
64
|
+
level="leaf",
|
|
65
|
+
content=content,
|
|
66
|
+
topic=topic,
|
|
67
|
+
conversation_id=None,
|
|
68
|
+
created_at=datetime.now(UTC),
|
|
69
|
+
updated_at=datetime.now(UTC),
|
|
70
|
+
)
|
|
71
|
+
self._backend.save_node(node)
|
|
72
|
+
return node
|
|
73
|
+
|
|
74
|
+
def recall(self, query: str, *, limit: int = 5) -> list[MemoryNode]:
|
|
75
|
+
"""Recall memories relevant to the query via FTS."""
|
|
76
|
+
return self._backend.search_memories(query, limit=limit)
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Conversation records
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def add_conversation(self, record: ConversationRecord) -> None:
|
|
83
|
+
"""Persist a standardized conversation record."""
|
|
84
|
+
self._backend.save_conversation(record)
|
|
85
|
+
|
|
86
|
+
def search(self, query: str, *, limit: int = 10) -> list[SearchResult]:
|
|
87
|
+
"""Full-text search across chat message content."""
|
|
88
|
+
return self._backend.search_messages(query, limit=limit)
|
|
89
|
+
|
|
90
|
+
def list_conversations(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
user: str | None = None,
|
|
94
|
+
after: datetime | None = None,
|
|
95
|
+
before: datetime | None = None,
|
|
96
|
+
limit: int = 20,
|
|
97
|
+
) -> list[ConversationSummary]:
|
|
98
|
+
"""List conversations with optional filters."""
|
|
99
|
+
return self._backend.list_conversations(user=user, after=after, before=before, limit=limit)
|
|
100
|
+
|
|
101
|
+
def get_conversation(self, conversation_id: str) -> ConversationRecord | None:
|
|
102
|
+
"""Get a full conversation by ID."""
|
|
103
|
+
return self._backend.get_conversation(conversation_id)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Memory tree
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def get_tree(self) -> list[MemoryNode]:
|
|
110
|
+
"""Get the full memory tree."""
|
|
111
|
+
return self._backend.get_tree()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Backend factory
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _resolve_memory_backend(
|
|
120
|
+
backend_type: str,
|
|
121
|
+
namespace: str,
|
|
122
|
+
config: dict[str, Any],
|
|
123
|
+
) -> MemoryBackend:
|
|
124
|
+
"""Resolve a backend type string to a MemoryBackend instance."""
|
|
125
|
+
if backend_type == "sqlite":
|
|
126
|
+
from harness_memory.backends.sqlite import SqliteMemoryBackend
|
|
127
|
+
|
|
128
|
+
return SqliteMemoryBackend(
|
|
129
|
+
namespace=namespace,
|
|
130
|
+
db_path=config.get("db_path", "~/.harness-memory/session.sqlite"),
|
|
131
|
+
)
|
|
132
|
+
elif backend_type == "postgres":
|
|
133
|
+
try:
|
|
134
|
+
from harness_memory.backends.postgres import PostgresMemoryBackend # type: ignore[import-untyped]
|
|
135
|
+
except ImportError as exc:
|
|
136
|
+
raise ImportError(
|
|
137
|
+
"PostgresMemoryBackend requires 'orcakit-harness-memory[postgres]'. "
|
|
138
|
+
"Install with: pip install 'orcakit-harness-memory[postgres]'"
|
|
139
|
+
) from exc
|
|
140
|
+
return PostgresMemoryBackend(namespace=namespace, **config) # type: ignore[no-any-return]
|
|
141
|
+
elif backend_type == "qdrant":
|
|
142
|
+
try:
|
|
143
|
+
from harness_memory.backends.qdrant import QdrantMemoryBackend # type: ignore[import-untyped]
|
|
144
|
+
except ImportError as exc:
|
|
145
|
+
raise ImportError(
|
|
146
|
+
"QdrantMemoryBackend requires 'orcakit-harness-memory[qdrant]'. "
|
|
147
|
+
"Install with: pip install 'orcakit-harness-memory[qdrant]'"
|
|
148
|
+
) from exc
|
|
149
|
+
return QdrantMemoryBackend(namespace=namespace, **config) # type: ignore[no-any-return]
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError(f"Unknown memory backend type: {backend_type!r}. Supported: 'sqlite', 'postgres', 'qdrant'")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = ["Memory"]
|
harness_memory/types.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Data types for the memory system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class MemoryNode:
|
|
12
|
+
"""A node in the memory tree (root, branch, or leaf)."""
|
|
13
|
+
|
|
14
|
+
id: str
|
|
15
|
+
parent_id: str | None
|
|
16
|
+
level: Literal["root", "branch", "leaf"]
|
|
17
|
+
content: str
|
|
18
|
+
topic: str | None
|
|
19
|
+
conversation_id: str | None
|
|
20
|
+
created_at: datetime
|
|
21
|
+
updated_at: datetime
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Message:
|
|
26
|
+
"""A single message in a conversation."""
|
|
27
|
+
|
|
28
|
+
role: Literal["user", "assistant", "tool"]
|
|
29
|
+
content: str
|
|
30
|
+
timestamp: datetime
|
|
31
|
+
tool_name: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ConversationRecord:
|
|
36
|
+
"""A complete conversation record."""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
thread_id: str
|
|
40
|
+
user: str | None
|
|
41
|
+
started_at: datetime
|
|
42
|
+
ended_at: datetime
|
|
43
|
+
messages: list[Message]
|
|
44
|
+
summary: str | None
|
|
45
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class SearchResult:
|
|
50
|
+
"""A search hit from FTS query."""
|
|
51
|
+
|
|
52
|
+
conversation_id: str
|
|
53
|
+
message_content: str
|
|
54
|
+
role: str
|
|
55
|
+
timestamp: datetime
|
|
56
|
+
score: float
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ConversationSummary:
|
|
61
|
+
"""Lightweight conversation listing entry."""
|
|
62
|
+
|
|
63
|
+
id: str
|
|
64
|
+
thread_id: str
|
|
65
|
+
user: str | None
|
|
66
|
+
started_at: datetime
|
|
67
|
+
summary: str | None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"ConversationRecord",
|
|
72
|
+
"ConversationSummary",
|
|
73
|
+
"MemoryNode",
|
|
74
|
+
"Message",
|
|
75
|
+
"SearchResult",
|
|
76
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: harness-memory
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Pluggable memory system with hierarchical recall, FTS search, and multiple backend support.
|
|
5
|
+
Project-URL: Homepage, https://github.com/orcakit/harness-memory
|
|
6
|
+
Project-URL: Repository, https://github.com/orcakit/harness-memory
|
|
7
|
+
Project-URL: Issues, https://github.com/orcakit/harness-memory/issues
|
|
8
|
+
Author: orcakit
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,fts,llm,memory,recall,sqlite
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
27
|
+
Provides-Extra: postgres
|
|
28
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
29
|
+
Provides-Extra: qdrant
|
|
30
|
+
Requires-Dist: qdrant-client>=1.7; extra == 'qdrant'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# orcakit-harness-memory
|
|
34
|
+
|
|
35
|
+
[](https://github.com/orcakit/harness-memory/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/orcakit-harness-memory/)
|
|
37
|
+
[](https://pypi.org/project/orcakit-harness-memory/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
Pluggable memory system for LLM agents — hierarchical memory tree with full-text search and multiple backend support.
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- **Memory Tree** — Hierarchical summaries (root → branch → leaf) for long-term recall
|
|
45
|
+
- **Full-Text Search** — FTS5/BM25 powered search across conversations and memories
|
|
46
|
+
- **Conversation Records** — Standardized chat history storage and retrieval
|
|
47
|
+
- **Pluggable Backends** — SQLite (default, zero-dep), PostgreSQL, Qdrant
|
|
48
|
+
- **Zero Dependencies** — Core package uses only Python stdlib + sqlite3
|
|
49
|
+
- **Namespace Isolation** — Multiple agents share one database safely
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install orcakit-harness-memory
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Optional backends:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install "orcakit-harness-memory[postgres]" # PostgreSQL backend
|
|
61
|
+
pip install "orcakit-harness-memory[qdrant]" # Qdrant vector backend
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quickstart
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from harness_memory import Memory
|
|
68
|
+
|
|
69
|
+
# Create with default SQLite backend
|
|
70
|
+
memory = Memory(namespace="my-agent")
|
|
71
|
+
|
|
72
|
+
# Store a memory
|
|
73
|
+
memory.store("User prefers Python over Java", topic="preferences")
|
|
74
|
+
|
|
75
|
+
# Recall relevant memories
|
|
76
|
+
results = memory.recall("programming language")
|
|
77
|
+
for node in results:
|
|
78
|
+
print(f"[{node.topic}] {node.content}")
|
|
79
|
+
|
|
80
|
+
# Add a conversation record
|
|
81
|
+
from harness_memory import ConversationRecord, Message
|
|
82
|
+
from datetime import datetime, timezone
|
|
83
|
+
|
|
84
|
+
record = ConversationRecord(
|
|
85
|
+
id="conv-001",
|
|
86
|
+
thread_id="thread-1",
|
|
87
|
+
user="alice",
|
|
88
|
+
started_at=datetime.now(timezone.utc),
|
|
89
|
+
ended_at=datetime.now(timezone.utc),
|
|
90
|
+
messages=[
|
|
91
|
+
Message(role="user", content="How do I use memory?", timestamp=datetime.now(timezone.utc)),
|
|
92
|
+
Message(role="assistant", content="Just call memory.store()!", timestamp=datetime.now(timezone.utc)),
|
|
93
|
+
],
|
|
94
|
+
summary=None,
|
|
95
|
+
metadata={},
|
|
96
|
+
)
|
|
97
|
+
memory.add_conversation(record)
|
|
98
|
+
|
|
99
|
+
# Search conversations
|
|
100
|
+
hits = memory.search("memory")
|
|
101
|
+
for hit in hits:
|
|
102
|
+
print(f"[{hit.role}] {hit.message_content}")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Backend Configuration
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# SQLite (default) — zero dependencies
|
|
109
|
+
memory = Memory(namespace="agent", backend="sqlite")
|
|
110
|
+
memory = Memory(namespace="agent", backend_config={"db_path": "/data/memory.db"})
|
|
111
|
+
|
|
112
|
+
# PostgreSQL — requires [postgres] extra
|
|
113
|
+
memory = Memory(namespace="agent", backend="postgres", backend_config={
|
|
114
|
+
"dsn": "postgresql://user:pass@localhost/memdb"
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
# Qdrant — requires [qdrant] extra
|
|
118
|
+
memory = Memory(namespace="agent", backend="qdrant", backend_config={
|
|
119
|
+
"url": "http://localhost:6333"
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
# Custom backend — pass any MemoryBackend instance
|
|
123
|
+
memory = Memory(namespace="agent", backend=my_custom_backend)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## API Reference
|
|
127
|
+
|
|
128
|
+
### Memory
|
|
129
|
+
|
|
130
|
+
| Method | Description |
|
|
131
|
+
|--------|-------------|
|
|
132
|
+
| `store(content, topic=None)` | Store a memory as a leaf node |
|
|
133
|
+
| `recall(query, limit=5)` | Recall relevant memories via FTS |
|
|
134
|
+
| `add_conversation(record)` | Persist a conversation record |
|
|
135
|
+
| `search(query, limit=10)` | Full-text search across messages |
|
|
136
|
+
| `list_conversations(**filters)` | List conversations with filters |
|
|
137
|
+
| `get_conversation(id)` | Get full conversation by ID |
|
|
138
|
+
| `get_tree()` | Get the complete memory tree |
|
|
139
|
+
|
|
140
|
+
### Data Types
|
|
141
|
+
|
|
142
|
+
- `MemoryNode` — A node in the memory tree (root/branch/leaf)
|
|
143
|
+
- `ConversationRecord` — Complete conversation with messages
|
|
144
|
+
- `Message` — Single message (user/assistant/tool)
|
|
145
|
+
- `SearchResult` — FTS search hit with score
|
|
146
|
+
- `ConversationSummary` — Lightweight conversation listing
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
make dev # Install dev dependencies
|
|
152
|
+
make lint # Ruff check + format check
|
|
153
|
+
make typecheck # mypy strict
|
|
154
|
+
make test # pytest
|
|
155
|
+
make all # lint + typecheck + test
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
harness_memory/__init__.py,sha256=mzFN-KzLxRrWN0AClOnN7p6_4l2j7eINTD0x_zelU50,545
|
|
2
|
+
harness_memory/_version.py,sha256=mEr1FdwwZVMovyM6vpPg2W8zwa1u1f4KdymTrLcqf9U,520
|
|
3
|
+
harness_memory/core.py,sha256=M5JmwiE8OFaw4L-5Hi_on2FiwzwvZWeqkNNqfii4EVc,5691
|
|
4
|
+
harness_memory/types.py,sha256=0NNWR9pAB11h848lsX17tdpquR0BEx5BkB_KLpVIaBY,1458
|
|
5
|
+
harness_memory/backends/__init__.py,sha256=fPFQfNyXNDjZIEmytuhF5EscS5WmDTyUmzSqiaJIHIQ,1307
|
|
6
|
+
harness_memory/backends/postgres.py,sha256=869pBprfgr0AWtdV7eMIADL9EScGj9MoCfPkHFgQEkw,13445
|
|
7
|
+
harness_memory/backends/sqlite.py,sha256=SuJeNyYPomBYNWIGm1Y_cPlvD1HjHI3ySVQmAoR0YqI,12665
|
|
8
|
+
harness_memory-0.1.1.dist-info/METADATA,sha256=ncXEV7OB0Nk9SS9z4wOcwmarIj0NkHPl9AT-FZHqXYk,5533
|
|
9
|
+
harness_memory-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
harness_memory-0.1.1.dist-info/licenses/LICENSE,sha256=KZQwXHx21B5YVep7xogAySPBO_wSTi3er7AUfUK5zuQ,1064
|
|
11
|
+
harness_memory-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 orcakit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|