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.
@@ -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"]
@@ -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
+ [![CI](https://github.com/orcakit/harness-memory/actions/workflows/ci.yml/badge.svg)](https://github.com/orcakit/harness-memory/actions/workflows/ci.yml)
36
+ [![PyPI version](https://img.shields.io/pypi/v/orcakit-harness-memory.svg)](https://pypi.org/project/orcakit-harness-memory/)
37
+ [![Python versions](https://img.shields.io/pypi/pyversions/orcakit-harness-memory.svg)](https://pypi.org/project/orcakit-harness-memory/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.