agentkernel-cli 0.1.0__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.
- agentkernel/__init__.py +7 -0
- agentkernel/__main__.py +5 -0
- agentkernel/agent.py +311 -0
- agentkernel/approval/__init__.py +23 -0
- agentkernel/approval/base.py +34 -0
- agentkernel/approval/cli.py +129 -0
- agentkernel/approval/policy.py +58 -0
- agentkernel/approval/risk.py +91 -0
- agentkernel/approval/sandbox.py +201 -0
- agentkernel/budget.py +64 -0
- agentkernel/checkpoint.py +50 -0
- agentkernel/cli.py +1482 -0
- agentkernel/config.py +224 -0
- agentkernel/context/__init__.py +17 -0
- agentkernel/context/manager.py +216 -0
- agentkernel/context/truncate.py +35 -0
- agentkernel/cron.py +146 -0
- agentkernel/curation.py +183 -0
- agentkernel/doctor.py +141 -0
- agentkernel/embeddings.py +132 -0
- agentkernel/evaluation.py +186 -0
- agentkernel/improvement.py +133 -0
- agentkernel/insights.py +141 -0
- agentkernel/kanban.py +114 -0
- agentkernel/knowledge.py +383 -0
- agentkernel/loops.py +145 -0
- agentkernel/mcp/__init__.py +23 -0
- agentkernel/mcp/client.py +181 -0
- agentkernel/mcp/config.py +59 -0
- agentkernel/mcp/tools.py +96 -0
- agentkernel/memory.py +1208 -0
- agentkernel/paths.py +73 -0
- agentkernel/plugins.py +76 -0
- agentkernel/profiles.py +70 -0
- agentkernel/progress.py +89 -0
- agentkernel/providers/__init__.py +35 -0
- agentkernel/providers/_http.py +157 -0
- agentkernel/providers/anthropic.py +282 -0
- agentkernel/providers/base.py +38 -0
- agentkernel/providers/credentials.py +65 -0
- agentkernel/providers/local.py +34 -0
- agentkernel/providers/openai.py +260 -0
- agentkernel/redaction.py +77 -0
- agentkernel/semantic_index.py +139 -0
- agentkernel/semantic_memory.py +253 -0
- agentkernel/skills.py +268 -0
- agentkernel/subagent.py +161 -0
- agentkernel/telemetry.py +199 -0
- agentkernel/templates/README.md +35 -0
- agentkernel/templates/SKILL.md +28 -0
- agentkernel/templates/eval-suite.toml +22 -0
- agentkernel/templates/loop.toml +29 -0
- agentkernel/templates/mcp-servers.toml +22 -0
- agentkernel/templates/profile.toml +29 -0
- agentkernel/templates/tool_module.py +64 -0
- agentkernel/tools/__init__.py +5 -0
- agentkernel/tools/base.py +100 -0
- agentkernel/tools/builtin/__init__.py +37 -0
- agentkernel/tools/builtin/checkpoint_tool.py +33 -0
- agentkernel/tools/builtin/clarify.py +60 -0
- agentkernel/tools/builtin/files.py +221 -0
- agentkernel/tools/builtin/kanban_tool.py +100 -0
- agentkernel/tools/builtin/search.py +225 -0
- agentkernel/tools/builtin/shell.py +67 -0
- agentkernel/tools/builtin/todo.py +106 -0
- agentkernel/tui/__init__.py +50 -0
- agentkernel/tui/app.py +594 -0
- agentkernel/types.py +127 -0
- agentkernel/worktree.py +64 -0
- agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
- agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
- agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
- agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentkernel_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
agentkernel/memory.py
ADDED
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
"""Persistent memory seam (Phase 3, design \u00a713).
|
|
2
|
+
|
|
3
|
+
A ``MemoryStore`` loads relevant prior context before a run and saves the
|
|
4
|
+
conversation after a run. It is deliberately minimal: the kernel only defines the
|
|
5
|
+
interface; concrete stores decide what to persist and how to recall it.
|
|
6
|
+
|
|
7
|
+
All stores operate on canonical ``Message`` objects so the loop never learns
|
|
8
|
+
where memory came from.
|
|
9
|
+
|
|
10
|
+
This module also exposes a model-controlled ``NoteStore``: discrete facts the
|
|
11
|
+
*model* chooses to write and read on demand (``remember`` / ``recall`` tools).
|
|
12
|
+
The default backend is an append-only JSONL notebook; a SQLite-backed store is
|
|
13
|
+
available for unified, full-text-searchable storage.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
import sqlite3
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Protocol
|
|
27
|
+
|
|
28
|
+
from agentkernel.types import Message
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MemoryStore(Protocol):
|
|
32
|
+
"""Pluggable memory: load before a run, save after it."""
|
|
33
|
+
|
|
34
|
+
def load(self, session_id: str) -> list[Message]:
|
|
35
|
+
"""Return messages to inject before the current run."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def save(self, session_id: str, messages: Sequence[Message]) -> None:
|
|
39
|
+
"""Persist the messages from the just-finished run."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def delete(self, session_id: str) -> None:
|
|
43
|
+
"""Remove any persisted messages for ``session_id``."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def list_sessions(self) -> list[str]:
|
|
47
|
+
"""Return known session ids (for management / cleanup)."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NoteStore(Protocol):
|
|
52
|
+
"""Pluggable notebook of discrete facts the model reads and writes."""
|
|
53
|
+
|
|
54
|
+
def add(self, text: str, *, tags: Sequence[str] | None = None) -> MemoryNote:
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def all(self) -> list[MemoryNote]:
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def recent(self, limit: int = 5) -> list[MemoryNote]:
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def search(self, query: str, *, limit: int = 5) -> list[MemoryNote]:
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
def forget(
|
|
67
|
+
self, *, note_id: int | None = None, text_prefix: str | None = None
|
|
68
|
+
) -> list[MemoryNote]:
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
def update(
|
|
72
|
+
self, note_id: int, text: str, *, tags: Sequence[str] | None = None
|
|
73
|
+
) -> MemoryNote | None:
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def deduplicate(self) -> int:
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def export(self, destination: str | Path) -> Path:
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class InMemoryMemoryStore:
|
|
85
|
+
"""Volatile memory for tests and ephemeral sessions."""
|
|
86
|
+
|
|
87
|
+
_data: dict[str, list[Message]] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
def load(self, session_id: str) -> list[Message]:
|
|
90
|
+
return list(self._data.get(session_id, []))
|
|
91
|
+
|
|
92
|
+
def save(self, session_id: str, messages: Sequence[Message]) -> None:
|
|
93
|
+
self._data[session_id] = list(messages)
|
|
94
|
+
|
|
95
|
+
def delete(self, session_id: str) -> None:
|
|
96
|
+
self._data.pop(session_id, None)
|
|
97
|
+
|
|
98
|
+
def list_sessions(self) -> list[str]:
|
|
99
|
+
return sorted(self._data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class FileMemoryStore:
|
|
104
|
+
"""Append-only JSONL memory on disk.
|
|
105
|
+
|
|
106
|
+
Each line is one serialized ``Message``. Saving rewrites the file so the
|
|
107
|
+
persisted view always matches the in-memory context for the session.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
directory: str | Path
|
|
111
|
+
|
|
112
|
+
def __post_init__(self) -> None:
|
|
113
|
+
self._dir = Path(self.directory)
|
|
114
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
def load(self, session_id: str) -> list[Message]:
|
|
117
|
+
path = self._path(session_id)
|
|
118
|
+
if not path.is_file():
|
|
119
|
+
return []
|
|
120
|
+
messages: list[Message] = []
|
|
121
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
122
|
+
for line in fh:
|
|
123
|
+
line = line.strip()
|
|
124
|
+
if not line:
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
messages.append(Message.from_dict(json.loads(line)))
|
|
128
|
+
except (json.JSONDecodeError, KeyError):
|
|
129
|
+
continue # corrupted line; skip rather than crash
|
|
130
|
+
return messages
|
|
131
|
+
|
|
132
|
+
def save(self, session_id: str, messages: Sequence[Message]) -> None:
|
|
133
|
+
path = self._path(session_id)
|
|
134
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
136
|
+
for message in messages:
|
|
137
|
+
fh.write(json.dumps(message.to_dict()) + "\n")
|
|
138
|
+
|
|
139
|
+
def delete(self, session_id: str) -> None:
|
|
140
|
+
path = self._path(session_id)
|
|
141
|
+
if path.is_file():
|
|
142
|
+
path.unlink()
|
|
143
|
+
|
|
144
|
+
def list_sessions(self) -> list[str]:
|
|
145
|
+
if not self._dir.is_dir():
|
|
146
|
+
return []
|
|
147
|
+
sessions: list[str] = []
|
|
148
|
+
for path in self._dir.iterdir():
|
|
149
|
+
if path.suffix == ".jsonl" and path.name != "notes.jsonl":
|
|
150
|
+
sessions.append(path.stem)
|
|
151
|
+
return sorted(sessions)
|
|
152
|
+
|
|
153
|
+
def _path(self, session_id: str) -> Path:
|
|
154
|
+
# Sanitize session_id enough for a filename; UUIDs are the normal input.
|
|
155
|
+
safe = "".join(c for c in session_id if c.isalnum() or c in "-_.")
|
|
156
|
+
return self._dir / f"{safe}.jsonl"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class SqliteMemoryStore:
|
|
161
|
+
"""SQLite-backed session memory.
|
|
162
|
+
|
|
163
|
+
Messages are stored relationally with optional FTS5 content search across
|
|
164
|
+
session transcripts. ``sqlite3`` is part of the Python stdlib, so this adds
|
|
165
|
+
no external dependency. If FTS5 is unavailable in the local build, the store
|
|
166
|
+
falls back to relational storage and search methods use a LIKE fallback.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
path: str | Path
|
|
170
|
+
|
|
171
|
+
def __post_init__(self) -> None:
|
|
172
|
+
self._path = Path(self.path)
|
|
173
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
self._conn: sqlite3.Connection | None = None
|
|
175
|
+
self._fts_enabled: bool | None = None
|
|
176
|
+
self._ensure_schema()
|
|
177
|
+
|
|
178
|
+
def _connection(self) -> sqlite3.Connection:
|
|
179
|
+
if self._conn is None:
|
|
180
|
+
self._conn = sqlite3.connect(str(self._path), check_same_thread=False)
|
|
181
|
+
self._conn.row_factory = sqlite3.Row
|
|
182
|
+
return self._conn
|
|
183
|
+
|
|
184
|
+
def _ensure_schema(self) -> None:
|
|
185
|
+
conn = self._connection()
|
|
186
|
+
conn.executescript(
|
|
187
|
+
"""
|
|
188
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
189
|
+
session_id TEXT PRIMARY KEY
|
|
190
|
+
);
|
|
191
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
192
|
+
id INTEGER PRIMARY KEY,
|
|
193
|
+
session_id TEXT NOT NULL,
|
|
194
|
+
content TEXT NOT NULL DEFAULT '',
|
|
195
|
+
payload_json TEXT NOT NULL,
|
|
196
|
+
position INTEGER NOT NULL
|
|
197
|
+
);
|
|
198
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session_position
|
|
199
|
+
ON messages(session_id, position);
|
|
200
|
+
"""
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
conn.execute(
|
|
204
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(content)"
|
|
205
|
+
)
|
|
206
|
+
self._fts_enabled = True
|
|
207
|
+
except sqlite3.OperationalError:
|
|
208
|
+
self._fts_enabled = False
|
|
209
|
+
conn.commit()
|
|
210
|
+
|
|
211
|
+
def load(self, session_id: str) -> list[Message]:
|
|
212
|
+
rows = self._connection().execute(
|
|
213
|
+
"""
|
|
214
|
+
SELECT payload_json
|
|
215
|
+
FROM messages
|
|
216
|
+
WHERE session_id = ?
|
|
217
|
+
ORDER BY position
|
|
218
|
+
""",
|
|
219
|
+
(session_id,),
|
|
220
|
+
).fetchall()
|
|
221
|
+
messages: list[Message] = []
|
|
222
|
+
for row in rows:
|
|
223
|
+
try:
|
|
224
|
+
messages.append(Message.from_dict(json.loads(row["payload_json"])))
|
|
225
|
+
except (json.JSONDecodeError, KeyError):
|
|
226
|
+
continue # skip corrupt records rather than crash
|
|
227
|
+
return messages
|
|
228
|
+
|
|
229
|
+
def save(self, session_id: str, messages: Sequence[Message]) -> None:
|
|
230
|
+
conn = self._connection()
|
|
231
|
+
with conn:
|
|
232
|
+
existing_ids = [
|
|
233
|
+
r["id"]
|
|
234
|
+
for r in conn.execute(
|
|
235
|
+
"SELECT id FROM messages WHERE session_id = ?", (session_id,)
|
|
236
|
+
).fetchall()
|
|
237
|
+
]
|
|
238
|
+
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
|
239
|
+
if self._fts_enabled:
|
|
240
|
+
for mid in existing_ids:
|
|
241
|
+
conn.execute("DELETE FROM messages_fts WHERE rowid = ?", (mid,))
|
|
242
|
+
conn.execute(
|
|
243
|
+
"INSERT OR REPLACE INTO sessions(session_id) VALUES (?)", (session_id,)
|
|
244
|
+
)
|
|
245
|
+
for position, message in enumerate(messages):
|
|
246
|
+
cursor = conn.execute(
|
|
247
|
+
"""
|
|
248
|
+
INSERT INTO messages
|
|
249
|
+
(session_id, content, payload_json, position)
|
|
250
|
+
VALUES (?, ?, ?, ?)
|
|
251
|
+
""",
|
|
252
|
+
(
|
|
253
|
+
session_id,
|
|
254
|
+
message.content,
|
|
255
|
+
json.dumps(message.to_dict()),
|
|
256
|
+
position,
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
if self._fts_enabled:
|
|
260
|
+
conn.execute(
|
|
261
|
+
"INSERT INTO messages_fts(rowid, content) VALUES (?, ?)",
|
|
262
|
+
(cursor.lastrowid, message.content),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def delete(self, session_id: str) -> None:
|
|
266
|
+
conn = self._connection()
|
|
267
|
+
with conn:
|
|
268
|
+
existing_ids = [
|
|
269
|
+
r["id"]
|
|
270
|
+
for r in conn.execute(
|
|
271
|
+
"SELECT id FROM messages WHERE session_id = ?", (session_id,)
|
|
272
|
+
).fetchall()
|
|
273
|
+
]
|
|
274
|
+
if self._fts_enabled:
|
|
275
|
+
for mid in existing_ids:
|
|
276
|
+
conn.execute("DELETE FROM messages_fts WHERE rowid = ?", (mid,))
|
|
277
|
+
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
|
278
|
+
conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
|
|
279
|
+
|
|
280
|
+
def list_sessions(self) -> list[str]:
|
|
281
|
+
rows = self._connection().execute(
|
|
282
|
+
"SELECT session_id FROM sessions ORDER BY session_id"
|
|
283
|
+
).fetchall()
|
|
284
|
+
return [r["session_id"] for r in rows]
|
|
285
|
+
|
|
286
|
+
def search_sessions(self, query: str, limit: int = 10) -> list[str]:
|
|
287
|
+
"""Return session_ids whose messages match ``query``.
|
|
288
|
+
|
|
289
|
+
Uses FTS5 MATCH when available; otherwise falls back to substring search
|
|
290
|
+
on message contents.
|
|
291
|
+
"""
|
|
292
|
+
query = query.strip()
|
|
293
|
+
if not query or not self._has_messages():
|
|
294
|
+
return []
|
|
295
|
+
conn = self._connection()
|
|
296
|
+
if self._fts_enabled:
|
|
297
|
+
try:
|
|
298
|
+
rows = conn.execute(
|
|
299
|
+
"""
|
|
300
|
+
SELECT DISTINCT m.session_id
|
|
301
|
+
FROM messages_fts f
|
|
302
|
+
JOIN messages m ON f.rowid = m.id
|
|
303
|
+
WHERE f MATCH ?
|
|
304
|
+
ORDER BY m.session_id
|
|
305
|
+
LIMIT ?
|
|
306
|
+
""",
|
|
307
|
+
(query, limit),
|
|
308
|
+
).fetchall()
|
|
309
|
+
return [r["session_id"] for r in rows]
|
|
310
|
+
except sqlite3.OperationalError:
|
|
311
|
+
pass # malformed FTS5 query; fall through to LIKE
|
|
312
|
+
like = f"%{query}%"
|
|
313
|
+
rows = conn.execute(
|
|
314
|
+
"""
|
|
315
|
+
SELECT DISTINCT session_id
|
|
316
|
+
FROM messages
|
|
317
|
+
WHERE content LIKE ?
|
|
318
|
+
ORDER BY session_id
|
|
319
|
+
LIMIT ?
|
|
320
|
+
""",
|
|
321
|
+
(like, limit),
|
|
322
|
+
).fetchall()
|
|
323
|
+
return [r["session_id"] for r in rows]
|
|
324
|
+
|
|
325
|
+
def _has_messages(self) -> bool:
|
|
326
|
+
return (
|
|
327
|
+
self._connection()
|
|
328
|
+
.execute("SELECT 1 FROM messages LIMIT 1")
|
|
329
|
+
.fetchone()
|
|
330
|
+
is not None
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def close(self) -> None:
|
|
334
|
+
if self._conn is not None:
|
|
335
|
+
self._conn.close()
|
|
336
|
+
self._conn = None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def make_memory_store(kind: str | None, directory: str | Path | None = None) -> MemoryStore | None:
|
|
340
|
+
"""Factory for the built-in memory stores."""
|
|
341
|
+
if kind == "file":
|
|
342
|
+
return FileMemoryStore(directory or ".agentkernel/memory")
|
|
343
|
+
if kind == "sqlite":
|
|
344
|
+
path = Path(directory or ".agentkernel/memory") / "memory.db"
|
|
345
|
+
return SqliteMemoryStore(path)
|
|
346
|
+
if kind == "memory":
|
|
347
|
+
return InMemoryMemoryStore()
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --- Token normalization for keyword search ---------------------------------
|
|
352
|
+
|
|
353
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _normalize_token(token: str) -> str:
|
|
357
|
+
"""Simple English-lite stemmer for better keyword recall.
|
|
358
|
+
|
|
359
|
+
Handles plurals, possessives, and the most common verb suffixes. This is a
|
|
360
|
+
dependency-free approximation; it intentionally keeps false positives low
|
|
361
|
+
for short tokens and avoids normalizing away useful distinctions.
|
|
362
|
+
"""
|
|
363
|
+
if len(token) <= 3:
|
|
364
|
+
return token
|
|
365
|
+
if token.endswith("'s"):
|
|
366
|
+
token = token[:-2]
|
|
367
|
+
if token.endswith("ies") and len(token) > 4:
|
|
368
|
+
token = token[:-3] + "y"
|
|
369
|
+
elif token.endswith("ses") and len(token) > 4:
|
|
370
|
+
token = token[:-2]
|
|
371
|
+
elif token.endswith("s") and not token.endswith("ss") and len(token) > 3:
|
|
372
|
+
token = token[:-1]
|
|
373
|
+
if token.endswith("ying") and len(token) > 5:
|
|
374
|
+
token = token[:-4] + "ie"
|
|
375
|
+
elif token.endswith("ing") and len(token) > 5:
|
|
376
|
+
token = token[:-3]
|
|
377
|
+
elif token.endswith("ied") and len(token) > 5:
|
|
378
|
+
token = token[:-3] + "y"
|
|
379
|
+
elif token.endswith("ed") and len(token) > 4:
|
|
380
|
+
token = token[:-2]
|
|
381
|
+
return token
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _tokens(text: str) -> set[str]:
|
|
385
|
+
return {_normalize_token(t) for t in _TOKEN_RE.findall(text.lower())}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# --- Model-controlled memory: remember / recall / forget tools ---------------
|
|
389
|
+
#
|
|
390
|
+
# Distinct from MemoryStore (which auto-loads/saves a session transcript): this
|
|
391
|
+
# is a persistent notebook of discrete facts the *model* chooses to write and
|
|
392
|
+
# read on demand, exposed as ordinary tools (the "everything is a tool"
|
|
393
|
+
# principle). The notebook is append-only JSONL and shared across sessions, so a
|
|
394
|
+
# fact remembered in one session is recallable in the next.
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass
|
|
398
|
+
class MemoryNote:
|
|
399
|
+
"""One remembered fact."""
|
|
400
|
+
|
|
401
|
+
text: str
|
|
402
|
+
tags: list[str] = field(default_factory=list)
|
|
403
|
+
created: str = ""
|
|
404
|
+
note_id: int = 0
|
|
405
|
+
accessed: str = "" # ISO timestamp of last recall/update (P1 metadata)
|
|
406
|
+
access_count: int = 0 # how many times the note has been recalled (P1)
|
|
407
|
+
|
|
408
|
+
def to_dict(self) -> dict:
|
|
409
|
+
return {
|
|
410
|
+
"text": self.text,
|
|
411
|
+
"tags": self.tags,
|
|
412
|
+
"created": self.created,
|
|
413
|
+
"note_id": self.note_id,
|
|
414
|
+
"accessed": self.accessed,
|
|
415
|
+
"access_count": self.access_count,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
@classmethod
|
|
419
|
+
def from_dict(cls, data: dict) -> MemoryNote:
|
|
420
|
+
return cls(
|
|
421
|
+
text=data.get("text", ""),
|
|
422
|
+
tags=list(data.get("tags", [])),
|
|
423
|
+
created=data.get("created", ""),
|
|
424
|
+
note_id=data.get("note_id", 0),
|
|
425
|
+
accessed=data.get("accessed", ""),
|
|
426
|
+
access_count=data.get("access_count", 0),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class JsonlNoteStore:
|
|
431
|
+
"""Append-only notebook of facts with keyword + recency recall.
|
|
432
|
+
|
|
433
|
+
Persistence is a single JSONL file (one note per line). Recall scores notes
|
|
434
|
+
by token overlap with the query and breaks ties toward more recent notes; an
|
|
435
|
+
empty query returns the most recent notes.
|
|
436
|
+
|
|
437
|
+
Notes are assigned stable IDs so they can be listed, updated, or forgotten
|
|
438
|
+
from the REPL or by the model via ordinary tools.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
def __init__(self, path: str | Path) -> None:
|
|
442
|
+
self.path = Path(path)
|
|
443
|
+
self._notes: list[MemoryNote] = []
|
|
444
|
+
self._next_id: int = 1
|
|
445
|
+
if self.path.is_file():
|
|
446
|
+
self._load()
|
|
447
|
+
|
|
448
|
+
def _load(self) -> None:
|
|
449
|
+
with self.path.open("r", encoding="utf-8") as fh:
|
|
450
|
+
for line in fh:
|
|
451
|
+
line = line.strip()
|
|
452
|
+
if not line:
|
|
453
|
+
continue
|
|
454
|
+
try:
|
|
455
|
+
note = MemoryNote.from_dict(json.loads(line))
|
|
456
|
+
except (json.JSONDecodeError, AttributeError):
|
|
457
|
+
continue # skip a corrupt line rather than crash
|
|
458
|
+
self._notes.append(note)
|
|
459
|
+
if note.note_id >= self._next_id:
|
|
460
|
+
self._next_id = note.note_id + 1
|
|
461
|
+
|
|
462
|
+
def _append_line(self, note: MemoryNote) -> None:
|
|
463
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
464
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
465
|
+
fh.write(json.dumps(note.to_dict()) + "\n")
|
|
466
|
+
|
|
467
|
+
def _rewrite(self) -> None:
|
|
468
|
+
"""Rewrite the file after a deletion or update."""
|
|
469
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
with self.path.open("w", encoding="utf-8") as fh:
|
|
471
|
+
for note in self._notes:
|
|
472
|
+
fh.write(json.dumps(note.to_dict()) + "\n")
|
|
473
|
+
|
|
474
|
+
def _touch(self, note: MemoryNote) -> None:
|
|
475
|
+
"""Record that a note was recalled. Updated in memory; a subsequent
|
|
476
|
+
rewrite will persist the new access metadata."""
|
|
477
|
+
note.access_count += 1
|
|
478
|
+
note.accessed = datetime.now(UTC).isoformat()
|
|
479
|
+
|
|
480
|
+
def add(
|
|
481
|
+
self, text: str, tags: Sequence[str] | None = None, *, note_id: int | None = None
|
|
482
|
+
) -> MemoryNote:
|
|
483
|
+
note = MemoryNote(
|
|
484
|
+
text=text.strip(),
|
|
485
|
+
tags=[str(t) for t in (tags or [])],
|
|
486
|
+
created=datetime.now(UTC).isoformat(),
|
|
487
|
+
note_id=note_id if note_id is not None else self._next_id,
|
|
488
|
+
)
|
|
489
|
+
if note.note_id >= self._next_id:
|
|
490
|
+
self._next_id = note.note_id + 1
|
|
491
|
+
self._notes.append(note)
|
|
492
|
+
self._append_line(note)
|
|
493
|
+
return note
|
|
494
|
+
|
|
495
|
+
def all(self) -> list[MemoryNote]:
|
|
496
|
+
return list(self._notes)
|
|
497
|
+
|
|
498
|
+
def recent(self, limit: int = 5) -> list[MemoryNote]:
|
|
499
|
+
results = self._notes[-limit:][::-1] # newest first
|
|
500
|
+
for note in results:
|
|
501
|
+
self._touch(note)
|
|
502
|
+
return results
|
|
503
|
+
|
|
504
|
+
def _tfidf_vectors(self) -> tuple[dict[str, float], list[dict[str, float]]]:
|
|
505
|
+
"""Compute sparse TF-IDF vectors for all notes.
|
|
506
|
+
|
|
507
|
+
Returns ``(idf, vectors)`` where each vector maps normalized token to
|
|
508
|
+
its TF-IDF weight. This is a dependency-free semantic approximation;
|
|
509
|
+
it ranks notes by cosine similarity rather than raw keyword overlap.
|
|
510
|
+
"""
|
|
511
|
+
df: dict[str, int] = {}
|
|
512
|
+
doc_tokens: list[set[str]] = []
|
|
513
|
+
for note in self._notes:
|
|
514
|
+
tokens = _tokens(note.text) | {_normalize_token(t) for t in note.tags}
|
|
515
|
+
doc_tokens.append(tokens)
|
|
516
|
+
for t in tokens:
|
|
517
|
+
df[t] = df.get(t, 0) + 1
|
|
518
|
+
num_docs = len(self._notes)
|
|
519
|
+
idf = {t: math.log((num_docs + 1) / (df[t] + 1)) + 1 for t in df}
|
|
520
|
+
vectors: list[dict[str, float]] = []
|
|
521
|
+
for tokens in doc_tokens:
|
|
522
|
+
total = len(tokens) or 1
|
|
523
|
+
vectors.append({t: (1 / total) * idf[t] for t in tokens})
|
|
524
|
+
return idf, vectors
|
|
525
|
+
|
|
526
|
+
def search(self, query: str, limit: int = 5) -> list[MemoryNote]:
|
|
527
|
+
terms = _tokens(query)
|
|
528
|
+
if not terms:
|
|
529
|
+
return self.recent(limit)
|
|
530
|
+
idf, vectors = self._tfidf_vectors()
|
|
531
|
+
query_total = len(terms) or 1
|
|
532
|
+
query_vec = {t: (1 / query_total) * idf.get(t, 0) for t in terms}
|
|
533
|
+
query_norm = math.sqrt(sum(v * v for v in query_vec.values())) or 1.0
|
|
534
|
+
scored: list[tuple[float, int, MemoryNote]] = []
|
|
535
|
+
for index, (note, vec) in enumerate(zip(self._notes, vectors, strict=True)):
|
|
536
|
+
if not vec:
|
|
537
|
+
continue
|
|
538
|
+
dot = sum(query_vec.get(t, 0) * vec.get(t, 0) for t in terms)
|
|
539
|
+
note_norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0
|
|
540
|
+
similarity = dot / (query_norm * note_norm)
|
|
541
|
+
if similarity > 0:
|
|
542
|
+
self._touch(note)
|
|
543
|
+
scored.append((similarity, index, note))
|
|
544
|
+
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
|
545
|
+
return [note for _score, _index, note in scored[:limit]]
|
|
546
|
+
|
|
547
|
+
def deduplicate(self) -> int:
|
|
548
|
+
"""Merge notes with identical text, combining tags and keeping the oldest id.
|
|
549
|
+
|
|
550
|
+
Returns the number of notes removed.
|
|
551
|
+
"""
|
|
552
|
+
seen: dict[str, MemoryNote] = {}
|
|
553
|
+
kept: list[MemoryNote] = []
|
|
554
|
+
removed = 0
|
|
555
|
+
for note in self._notes:
|
|
556
|
+
text = note.text.strip().lower()
|
|
557
|
+
if text in seen:
|
|
558
|
+
existing = seen[text]
|
|
559
|
+
existing.tags = sorted(set(existing.tags) | set(note.tags))
|
|
560
|
+
if note.access_count:
|
|
561
|
+
existing.access_count += note.access_count
|
|
562
|
+
removed += 1
|
|
563
|
+
else:
|
|
564
|
+
seen[text] = note
|
|
565
|
+
kept.append(note)
|
|
566
|
+
if removed:
|
|
567
|
+
self._notes = kept
|
|
568
|
+
self._rewrite()
|
|
569
|
+
return removed
|
|
570
|
+
|
|
571
|
+
def forget(
|
|
572
|
+
self, *, note_id: int | None = None, text_prefix: str | None = None
|
|
573
|
+
) -> list[MemoryNote]:
|
|
574
|
+
"""Remove notes matching ``note_id`` or whose text starts with ``text_prefix``.
|
|
575
|
+
|
|
576
|
+
Returns the notes that were removed.
|
|
577
|
+
"""
|
|
578
|
+
removed: list[MemoryNote] = []
|
|
579
|
+
remaining: list[MemoryNote] = []
|
|
580
|
+
prefix = (text_prefix or "").strip().lower()
|
|
581
|
+
for note in self._notes:
|
|
582
|
+
if (note_id is not None and note.note_id == note_id) or (
|
|
583
|
+
prefix and note.text.lower().startswith(prefix)
|
|
584
|
+
):
|
|
585
|
+
removed.append(note)
|
|
586
|
+
else:
|
|
587
|
+
remaining.append(note)
|
|
588
|
+
self._notes = remaining
|
|
589
|
+
if removed:
|
|
590
|
+
self._rewrite()
|
|
591
|
+
return removed
|
|
592
|
+
|
|
593
|
+
def update(
|
|
594
|
+
self, note_id: int, text: str, tags: Sequence[str] | None = None
|
|
595
|
+
) -> MemoryNote | None:
|
|
596
|
+
"""Replace the text/tags of an existing note in place."""
|
|
597
|
+
for note in self._notes:
|
|
598
|
+
if note.note_id == note_id:
|
|
599
|
+
note.text = text.strip()
|
|
600
|
+
note.tags = [str(t) for t in (tags or [])]
|
|
601
|
+
note.accessed = datetime.now(UTC).isoformat()
|
|
602
|
+
self._rewrite()
|
|
603
|
+
return note
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
def export(self, destination: str | Path) -> Path:
|
|
607
|
+
"""Write a human-readable markdown summary of all notes."""
|
|
608
|
+
dest = Path(destination)
|
|
609
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
610
|
+
lines: list[str] = ["# Memory Notes\n"]
|
|
611
|
+
for note in self._notes:
|
|
612
|
+
tag_part = f" *[{', '.join(note.tags)}]*" if note.tags else ""
|
|
613
|
+
access_part = f", accessed {note.access_count} time(s)" if note.access_count else ""
|
|
614
|
+
lines.append(
|
|
615
|
+
f"- {note.text}{tag_part} (id={note.note_id}, {note.created}{access_part})\n"
|
|
616
|
+
)
|
|
617
|
+
dest.write_text("".join(lines), encoding="utf-8")
|
|
618
|
+
return dest
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# Backward-compatible alias for code that directly constructs the JSONL notebook.
|
|
622
|
+
MemoryNotes = JsonlNoteStore
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def make_note_store(path: str | Path) -> NoteStore:
|
|
626
|
+
"""Select a note store backend based on the path extension.
|
|
627
|
+
|
|
628
|
+
Paths ending in ``.db``/``.sqlite``/``.sqlite3`` become a SQLite-backed
|
|
629
|
+
store; anything else uses the original JSONL notebook.
|
|
630
|
+
"""
|
|
631
|
+
p = Path(path)
|
|
632
|
+
if p.suffix.lower() in (".db", ".sqlite", ".sqlite3"):
|
|
633
|
+
return SqliteNoteStore(p)
|
|
634
|
+
return JsonlNoteStore(p)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def make_memory_tools(notes: NoteStore, store: MemoryStore | None = None) -> list:
|
|
638
|
+
"""Build the memory-management tools over a notebook and optional session store.
|
|
639
|
+
|
|
640
|
+
``remember`` is ungated: writes go only to the dedicated notebook file, so
|
|
641
|
+
the model can manage its own memory autonomously (cf. Anthropic's memory
|
|
642
|
+
tool) without an approval prompt per fact.
|
|
643
|
+
"""
|
|
644
|
+
from agentkernel.tools.base import ToolSpec
|
|
645
|
+
from agentkernel.types import ToolResult
|
|
646
|
+
|
|
647
|
+
def remember(arguments: dict) -> ToolResult:
|
|
648
|
+
text = arguments["text"]
|
|
649
|
+
note = notes.add(text, tags=arguments.get("tags"))
|
|
650
|
+
suffix = f" [tags: {', '.join(note.tags)}]" if note.tags else ""
|
|
651
|
+
return ToolResult("", f"Remembered: {note.text}{suffix}")
|
|
652
|
+
|
|
653
|
+
def recall(arguments: dict) -> ToolResult:
|
|
654
|
+
query = arguments.get("query", "") or ""
|
|
655
|
+
limit = int(arguments.get("limit", 5))
|
|
656
|
+
results = notes.search(query, limit=limit) if query else notes.recent(limit)
|
|
657
|
+
if not results:
|
|
658
|
+
return ToolResult("", "(no relevant memories)")
|
|
659
|
+
lines = [
|
|
660
|
+
f"- [{n.note_id}] {n.text}" + (f" [tags: {', '.join(n.tags)}]" if n.tags else "")
|
|
661
|
+
for n in results
|
|
662
|
+
]
|
|
663
|
+
return ToolResult("", "\n".join(lines))
|
|
664
|
+
|
|
665
|
+
def forget(arguments: dict) -> ToolResult:
|
|
666
|
+
note_id = arguments.get("note_id")
|
|
667
|
+
if note_id is not None:
|
|
668
|
+
note_id = int(note_id)
|
|
669
|
+
removed = notes.forget(note_id=note_id, text_prefix=arguments.get("text_prefix", ""))
|
|
670
|
+
if not removed:
|
|
671
|
+
return ToolResult("", "(no matching memories)")
|
|
672
|
+
return ToolResult("", f"Forgot {len(removed)} memory(s).")
|
|
673
|
+
|
|
674
|
+
def update_memory(arguments: dict) -> ToolResult:
|
|
675
|
+
note_id = int(arguments["note_id"])
|
|
676
|
+
note = notes.update(note_id, arguments["text"], tags=arguments.get("tags"))
|
|
677
|
+
if note is None:
|
|
678
|
+
return ToolResult("", f"No note with id={note_id}.", is_error=True)
|
|
679
|
+
return ToolResult("", f"Updated note {note_id}.")
|
|
680
|
+
|
|
681
|
+
def memory_stats(arguments: dict) -> ToolResult:
|
|
682
|
+
total = len(notes.all())
|
|
683
|
+
if not total:
|
|
684
|
+
return ToolResult("", "No memory notes stored yet.")
|
|
685
|
+
by_access = sorted(notes.all(), key=lambda n: n.access_count, reverse=True)[:5]
|
|
686
|
+
lines = [f"Total notes: {total}"]
|
|
687
|
+
if by_access and by_access[0].access_count:
|
|
688
|
+
lines.append("Most recalled:")
|
|
689
|
+
lines.extend(f" [{n.note_id}] {n.text} ({n.access_count})" for n in by_access)
|
|
690
|
+
newest = notes.recent(1)[0] if notes.all() else None
|
|
691
|
+
if newest:
|
|
692
|
+
lines.append(f"Newest note: [{newest.note_id}] {newest.text} ({newest.created})")
|
|
693
|
+
return ToolResult("", "\n".join(lines))
|
|
694
|
+
|
|
695
|
+
def deduplicate_memory(arguments: dict) -> ToolResult:
|
|
696
|
+
removed = notes.deduplicate()
|
|
697
|
+
return ToolResult(
|
|
698
|
+
"", f"Removed {removed} duplicate note(s). {len(notes.all())} unique note(s) remain."
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
tools = [
|
|
702
|
+
ToolSpec(
|
|
703
|
+
name="remember",
|
|
704
|
+
description=(
|
|
705
|
+
"Save a durable fact to long-term memory (persists across "
|
|
706
|
+
"sessions). Use for stable user preferences, project facts, and "
|
|
707
|
+
"decisions worth recalling later — not transient chatter."
|
|
708
|
+
),
|
|
709
|
+
parameters={
|
|
710
|
+
"type": "object",
|
|
711
|
+
"properties": {
|
|
712
|
+
"text": {"type": "string", "description": "The fact to remember."},
|
|
713
|
+
"tags": {
|
|
714
|
+
"type": "array",
|
|
715
|
+
"items": {"type": "string"},
|
|
716
|
+
"description": "Optional keywords to aid later recall.",
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
"required": ["text"],
|
|
720
|
+
"additionalProperties": False,
|
|
721
|
+
},
|
|
722
|
+
handler=remember,
|
|
723
|
+
category="memory",
|
|
724
|
+
),
|
|
725
|
+
ToolSpec(
|
|
726
|
+
name="recall",
|
|
727
|
+
description=(
|
|
728
|
+
"Search long-term memory for relevant facts. Provide a query to "
|
|
729
|
+
"find related notes, or omit it for the most recent ones. Note IDs "
|
|
730
|
+
"are shown so you can update or forget them later."
|
|
731
|
+
),
|
|
732
|
+
parameters={
|
|
733
|
+
"type": "object",
|
|
734
|
+
"properties": {
|
|
735
|
+
"query": {"type": "string", "description": "What to search for."},
|
|
736
|
+
"limit": {"type": "integer", "description": "Max notes to return."},
|
|
737
|
+
},
|
|
738
|
+
"additionalProperties": False,
|
|
739
|
+
},
|
|
740
|
+
handler=recall,
|
|
741
|
+
category="memory",
|
|
742
|
+
),
|
|
743
|
+
ToolSpec(
|
|
744
|
+
name="forget",
|
|
745
|
+
description=(
|
|
746
|
+
"Remove one or more durable facts from long-term memory. Match by "
|
|
747
|
+
"exact note_id (preferred) or by deleting every note whose text "
|
|
748
|
+
"starts with text_prefix."
|
|
749
|
+
),
|
|
750
|
+
parameters={
|
|
751
|
+
"type": "object",
|
|
752
|
+
"properties": {
|
|
753
|
+
"note_id": {
|
|
754
|
+
"type": "integer",
|
|
755
|
+
"description": "Exact id of the note to remove.",
|
|
756
|
+
},
|
|
757
|
+
"text_prefix": {
|
|
758
|
+
"type": "string",
|
|
759
|
+
"description": "Remove notes whose text starts with this string.",
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
"additionalProperties": False,
|
|
763
|
+
},
|
|
764
|
+
handler=forget,
|
|
765
|
+
category="memory",
|
|
766
|
+
),
|
|
767
|
+
ToolSpec(
|
|
768
|
+
name="update_memory",
|
|
769
|
+
description=(
|
|
770
|
+
"Replace the text and optional tags of an existing memory note "
|
|
771
|
+
"by its note_id. Use when a fact changes rather than deleting and "
|
|
772
|
+
"re-adding it."
|
|
773
|
+
),
|
|
774
|
+
parameters={
|
|
775
|
+
"type": "object",
|
|
776
|
+
"properties": {
|
|
777
|
+
"note_id": {
|
|
778
|
+
"type": "integer",
|
|
779
|
+
"description": "Exact id of the note to update.",
|
|
780
|
+
},
|
|
781
|
+
"text": {"type": "string", "description": "New note text."},
|
|
782
|
+
"tags": {
|
|
783
|
+
"type": "array",
|
|
784
|
+
"items": {"type": "string"},
|
|
785
|
+
"description": "Optional replacement tags.",
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
"required": ["note_id", "text"],
|
|
789
|
+
"additionalProperties": False,
|
|
790
|
+
},
|
|
791
|
+
handler=update_memory,
|
|
792
|
+
category="memory",
|
|
793
|
+
),
|
|
794
|
+
ToolSpec(
|
|
795
|
+
name="memory_stats",
|
|
796
|
+
description=(
|
|
797
|
+
"Show summary statistics about the long-term memory notebook: "
|
|
798
|
+
"total notes, most-recalled facts, and the newest note."
|
|
799
|
+
),
|
|
800
|
+
parameters={
|
|
801
|
+
"type": "object",
|
|
802
|
+
"properties": {},
|
|
803
|
+
"additionalProperties": False,
|
|
804
|
+
},
|
|
805
|
+
handler=memory_stats,
|
|
806
|
+
category="memory",
|
|
807
|
+
),
|
|
808
|
+
ToolSpec(
|
|
809
|
+
name="deduplicate_memory",
|
|
810
|
+
description=(
|
|
811
|
+
"Merge duplicate notes (identical text) by combining their tags "
|
|
812
|
+
"and access counts. Call this when the notebook feels cluttered "
|
|
813
|
+
"or the user asks to clean up redundant facts."
|
|
814
|
+
),
|
|
815
|
+
parameters={
|
|
816
|
+
"type": "object",
|
|
817
|
+
"properties": {},
|
|
818
|
+
"additionalProperties": False,
|
|
819
|
+
},
|
|
820
|
+
handler=deduplicate_memory,
|
|
821
|
+
category="memory",
|
|
822
|
+
),
|
|
823
|
+
]
|
|
824
|
+
|
|
825
|
+
if store is not None:
|
|
826
|
+
def list_sessions(arguments: dict) -> ToolResult:
|
|
827
|
+
sessions = store.list_sessions()
|
|
828
|
+
if not sessions:
|
|
829
|
+
return ToolResult("", "(no saved sessions)")
|
|
830
|
+
return ToolResult("", "Saved session IDs:\n" + "\n".join(f"- {s}" for s in sessions))
|
|
831
|
+
|
|
832
|
+
def delete_session(arguments: dict) -> ToolResult:
|
|
833
|
+
session_id = arguments["session_id"]
|
|
834
|
+
store.delete(session_id)
|
|
835
|
+
return ToolResult("", f"Deleted session {session_id}.")
|
|
836
|
+
|
|
837
|
+
tools.extend([
|
|
838
|
+
ToolSpec(
|
|
839
|
+
name="list_sessions",
|
|
840
|
+
description=(
|
|
841
|
+
"List IDs of previously persisted conversation sessions. Use "
|
|
842
|
+
"this when the user asks about history from another session."
|
|
843
|
+
),
|
|
844
|
+
parameters={
|
|
845
|
+
"type": "object",
|
|
846
|
+
"properties": {},
|
|
847
|
+
"additionalProperties": False,
|
|
848
|
+
},
|
|
849
|
+
handler=list_sessions,
|
|
850
|
+
category="memory",
|
|
851
|
+
),
|
|
852
|
+
ToolSpec(
|
|
853
|
+
name="delete_session",
|
|
854
|
+
description=(
|
|
855
|
+
"Delete a previously persisted conversation session by its "
|
|
856
|
+
"session_id. This is permanent: the transcript will not be "
|
|
857
|
+
"loaded in future runs."
|
|
858
|
+
),
|
|
859
|
+
parameters={
|
|
860
|
+
"type": "object",
|
|
861
|
+
"properties": {
|
|
862
|
+
"session_id": {"type": "string", "description": "Session ID to delete."},
|
|
863
|
+
},
|
|
864
|
+
"required": ["session_id"],
|
|
865
|
+
"additionalProperties": False,
|
|
866
|
+
},
|
|
867
|
+
handler=delete_session,
|
|
868
|
+
category="memory",
|
|
869
|
+
),
|
|
870
|
+
])
|
|
871
|
+
|
|
872
|
+
if hasattr(store, "search_sessions"):
|
|
873
|
+
def search_sessions(arguments: dict) -> ToolResult:
|
|
874
|
+
query = arguments.get("query", "") or ""
|
|
875
|
+
if not query:
|
|
876
|
+
return ToolResult("", "usage: provide a query", is_error=True)
|
|
877
|
+
limit = int(arguments.get("limit", 10))
|
|
878
|
+
results = store.search_sessions(query, limit=limit) # type: ignore[attr-defined]
|
|
879
|
+
if not results:
|
|
880
|
+
return ToolResult("", "(no matching sessions)")
|
|
881
|
+
return ToolResult("", "Matching sessions:\n" + "\n".join(f"- {s}" for s in results))
|
|
882
|
+
|
|
883
|
+
tools.append(
|
|
884
|
+
ToolSpec(
|
|
885
|
+
name="search_sessions",
|
|
886
|
+
description=(
|
|
887
|
+
"Search saved conversation sessions for those containing "
|
|
888
|
+
"messages that match the query. Uses full-text search "
|
|
889
|
+
"when the underlying store supports it."
|
|
890
|
+
),
|
|
891
|
+
parameters={
|
|
892
|
+
"type": "object",
|
|
893
|
+
"properties": {
|
|
894
|
+
"query": {
|
|
895
|
+
"type": "string",
|
|
896
|
+
"description": "Words to search for in session messages.",
|
|
897
|
+
},
|
|
898
|
+
"limit": {"type": "integer", "description": "Max sessions to return."},
|
|
899
|
+
},
|
|
900
|
+
"required": ["query"],
|
|
901
|
+
"additionalProperties": False,
|
|
902
|
+
},
|
|
903
|
+
handler=search_sessions,
|
|
904
|
+
category="memory",
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
if hasattr(notes, "reindex_embeddings"):
|
|
909
|
+
def reindex_memory(arguments: dict) -> ToolResult:
|
|
910
|
+
count = notes.reindex_embeddings()
|
|
911
|
+
return ToolResult("", f"Reindexed {count} note(s) for semantic search.")
|
|
912
|
+
|
|
913
|
+
tools.append(
|
|
914
|
+
ToolSpec(
|
|
915
|
+
name="reindex_memory",
|
|
916
|
+
description=(
|
|
917
|
+
"Recompute missing dense embeddings for semantic note recall. "
|
|
918
|
+
"Use this after enabling semantic_search or restoring a notebook."
|
|
919
|
+
),
|
|
920
|
+
parameters={
|
|
921
|
+
"type": "object",
|
|
922
|
+
"properties": {},
|
|
923
|
+
"additionalProperties": False,
|
|
924
|
+
},
|
|
925
|
+
handler=reindex_memory,
|
|
926
|
+
category="memory",
|
|
927
|
+
)
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
return tools
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
class SqliteNoteStore:
|
|
934
|
+
"""SQLite-backed notebook with full-text recall.
|
|
935
|
+
|
|
936
|
+
Uses the same ``MemoryNote`` model as ``JsonlNoteStore`` but persists in a
|
|
937
|
+
relational table. An optional FTS5 index is created for fast text search;
|
|
938
|
+
builds without FTS5 fall back to substring search.
|
|
939
|
+
"""
|
|
940
|
+
|
|
941
|
+
def __init__(self, path: str | Path) -> None:
|
|
942
|
+
self._path = Path(path)
|
|
943
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
944
|
+
self._conn: sqlite3.Connection | None = None
|
|
945
|
+
self._fts_enabled: bool | None = None
|
|
946
|
+
self._ensure_schema()
|
|
947
|
+
|
|
948
|
+
def _connection(self) -> sqlite3.Connection:
|
|
949
|
+
if self._conn is None:
|
|
950
|
+
self._conn = sqlite3.connect(str(self._path), check_same_thread=False)
|
|
951
|
+
self._conn.row_factory = sqlite3.Row
|
|
952
|
+
return self._conn
|
|
953
|
+
|
|
954
|
+
def _ensure_schema(self) -> None:
|
|
955
|
+
conn = self._connection()
|
|
956
|
+
conn.executescript(
|
|
957
|
+
"""
|
|
958
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
959
|
+
note_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
960
|
+
text TEXT NOT NULL,
|
|
961
|
+
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
962
|
+
created TEXT NOT NULL,
|
|
963
|
+
accessed TEXT,
|
|
964
|
+
access_count INTEGER NOT NULL DEFAULT 0
|
|
965
|
+
);
|
|
966
|
+
"""
|
|
967
|
+
)
|
|
968
|
+
try:
|
|
969
|
+
conn.execute(
|
|
970
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(text)"
|
|
971
|
+
)
|
|
972
|
+
self._fts_enabled = True
|
|
973
|
+
except sqlite3.OperationalError:
|
|
974
|
+
self._fts_enabled = False
|
|
975
|
+
conn.commit()
|
|
976
|
+
|
|
977
|
+
def _row_to_note(self, row: sqlite3.Row) -> MemoryNote:
|
|
978
|
+
return MemoryNote(
|
|
979
|
+
text=row["text"],
|
|
980
|
+
tags=json.loads(row["tags_json"]),
|
|
981
|
+
created=row["created"],
|
|
982
|
+
note_id=row["note_id"],
|
|
983
|
+
accessed=row["accessed"] or "",
|
|
984
|
+
access_count=row["access_count"],
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
def add(self, text: str, *, tags: Sequence[str] | None = None) -> MemoryNote:
|
|
988
|
+
created = datetime.now(UTC).isoformat()
|
|
989
|
+
conn = self._connection()
|
|
990
|
+
with conn:
|
|
991
|
+
cursor = conn.execute(
|
|
992
|
+
"""
|
|
993
|
+
INSERT INTO notes (text, tags_json, created, accessed, access_count)
|
|
994
|
+
VALUES (?, ?, ?, ?, ?)
|
|
995
|
+
""",
|
|
996
|
+
(
|
|
997
|
+
text.strip(),
|
|
998
|
+
json.dumps([str(t) for t in (tags or [])]),
|
|
999
|
+
created,
|
|
1000
|
+
"",
|
|
1001
|
+
0,
|
|
1002
|
+
),
|
|
1003
|
+
)
|
|
1004
|
+
note_id = cursor.lastrowid or 0
|
|
1005
|
+
if self._fts_enabled:
|
|
1006
|
+
conn.execute(
|
|
1007
|
+
"INSERT INTO notes_fts(rowid, text) VALUES (?, ?)",
|
|
1008
|
+
(note_id, text.strip()),
|
|
1009
|
+
)
|
|
1010
|
+
note = MemoryNote(
|
|
1011
|
+
text=text.strip(),
|
|
1012
|
+
tags=[str(t) for t in (tags or [])],
|
|
1013
|
+
created=created,
|
|
1014
|
+
note_id=note_id,
|
|
1015
|
+
accessed="",
|
|
1016
|
+
access_count=0,
|
|
1017
|
+
)
|
|
1018
|
+
return note
|
|
1019
|
+
|
|
1020
|
+
def all(self) -> list[MemoryNote]:
|
|
1021
|
+
rows = self._connection().execute(
|
|
1022
|
+
"SELECT * FROM notes ORDER BY note_id"
|
|
1023
|
+
).fetchall()
|
|
1024
|
+
return [self._row_to_note(r) for r in rows]
|
|
1025
|
+
|
|
1026
|
+
def recent(self, limit: int = 5) -> list[MemoryNote]:
|
|
1027
|
+
rows = self._connection().execute(
|
|
1028
|
+
"SELECT * FROM notes ORDER BY note_id DESC LIMIT ?",
|
|
1029
|
+
(limit,),
|
|
1030
|
+
).fetchall()
|
|
1031
|
+
notes = [self._row_to_note(r) for r in rows]
|
|
1032
|
+
for note in notes:
|
|
1033
|
+
self._touch(note)
|
|
1034
|
+
return notes
|
|
1035
|
+
|
|
1036
|
+
def search(self, query: str, *, limit: int = 5) -> list[MemoryNote]:
|
|
1037
|
+
query = query.strip()
|
|
1038
|
+
if not query:
|
|
1039
|
+
return self.recent(limit)
|
|
1040
|
+
conn = self._connection()
|
|
1041
|
+
rows: list[sqlite3.Row] = []
|
|
1042
|
+
if self._fts_enabled:
|
|
1043
|
+
try:
|
|
1044
|
+
rows = conn.execute(
|
|
1045
|
+
"""
|
|
1046
|
+
SELECT n.*
|
|
1047
|
+
FROM notes_fts f
|
|
1048
|
+
JOIN notes n ON f.rowid = n.note_id
|
|
1049
|
+
WHERE f MATCH ?
|
|
1050
|
+
ORDER BY rank
|
|
1051
|
+
LIMIT ?
|
|
1052
|
+
""",
|
|
1053
|
+
(query, limit),
|
|
1054
|
+
).fetchall()
|
|
1055
|
+
except sqlite3.OperationalError:
|
|
1056
|
+
rows = []
|
|
1057
|
+
if not rows:
|
|
1058
|
+
like = f"%{query}%"
|
|
1059
|
+
rows = conn.execute(
|
|
1060
|
+
"""
|
|
1061
|
+
SELECT * FROM notes
|
|
1062
|
+
WHERE text LIKE ?
|
|
1063
|
+
ORDER BY note_id DESC
|
|
1064
|
+
LIMIT ?
|
|
1065
|
+
""",
|
|
1066
|
+
(like, limit),
|
|
1067
|
+
).fetchall()
|
|
1068
|
+
notes = [self._row_to_note(r) for r in rows]
|
|
1069
|
+
for note in notes:
|
|
1070
|
+
self._touch(note)
|
|
1071
|
+
return notes
|
|
1072
|
+
|
|
1073
|
+
def forget(
|
|
1074
|
+
self, *, note_id: int | None = None, text_prefix: str | None = None
|
|
1075
|
+
) -> list[MemoryNote]:
|
|
1076
|
+
if note_id is None and not text_prefix:
|
|
1077
|
+
return []
|
|
1078
|
+
removed: list[MemoryNote] = []
|
|
1079
|
+
conn = self._connection()
|
|
1080
|
+
with conn:
|
|
1081
|
+
if note_id is not None:
|
|
1082
|
+
rows = conn.execute(
|
|
1083
|
+
"SELECT * FROM notes WHERE note_id = ?", (note_id,)
|
|
1084
|
+
).fetchall()
|
|
1085
|
+
removed = [self._row_to_note(r) for r in rows]
|
|
1086
|
+
self._delete_by_ids([r["note_id"] for r in rows])
|
|
1087
|
+
elif text_prefix:
|
|
1088
|
+
prefix = text_prefix.strip().lower()
|
|
1089
|
+
rows = conn.execute(
|
|
1090
|
+
"SELECT * FROM notes WHERE LOWER(text) LIKE ?",
|
|
1091
|
+
(f"{prefix}%",),
|
|
1092
|
+
).fetchall()
|
|
1093
|
+
removed = [self._row_to_note(r) for r in rows]
|
|
1094
|
+
self._delete_by_ids([r["note_id"] for r in rows])
|
|
1095
|
+
return removed
|
|
1096
|
+
|
|
1097
|
+
def _delete_by_ids(self, ids: Sequence[int]) -> None:
|
|
1098
|
+
if not ids:
|
|
1099
|
+
return
|
|
1100
|
+
placeholders = ",".join("?" for _ in ids)
|
|
1101
|
+
conn = self._connection()
|
|
1102
|
+
with conn:
|
|
1103
|
+
if self._fts_enabled:
|
|
1104
|
+
conn.execute(
|
|
1105
|
+
f"DELETE FROM notes_fts WHERE rowid IN ({placeholders})",
|
|
1106
|
+
tuple(ids),
|
|
1107
|
+
)
|
|
1108
|
+
conn.execute(
|
|
1109
|
+
f"DELETE FROM notes WHERE note_id IN ({placeholders})",
|
|
1110
|
+
tuple(ids),
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
def update(
|
|
1114
|
+
self, note_id: int, text: str, *, tags: Sequence[str] | None = None
|
|
1115
|
+
) -> MemoryNote | None:
|
|
1116
|
+
accessed = datetime.now(UTC).isoformat()
|
|
1117
|
+
conn = self._connection()
|
|
1118
|
+
with conn:
|
|
1119
|
+
existing = conn.execute(
|
|
1120
|
+
"SELECT * FROM notes WHERE note_id = ?", (note_id,)
|
|
1121
|
+
).fetchone()
|
|
1122
|
+
if existing is None:
|
|
1123
|
+
return None
|
|
1124
|
+
if self._fts_enabled:
|
|
1125
|
+
conn.execute("DELETE FROM notes_fts WHERE rowid = ?", (note_id,))
|
|
1126
|
+
conn.execute(
|
|
1127
|
+
"""
|
|
1128
|
+
UPDATE notes
|
|
1129
|
+
SET text = ?, tags_json = ?, accessed = ?,
|
|
1130
|
+
access_count = access_count + 1
|
|
1131
|
+
WHERE note_id = ?
|
|
1132
|
+
""",
|
|
1133
|
+
(
|
|
1134
|
+
text.strip(),
|
|
1135
|
+
json.dumps([str(t) for t in (tags or [])]),
|
|
1136
|
+
accessed,
|
|
1137
|
+
note_id,
|
|
1138
|
+
),
|
|
1139
|
+
)
|
|
1140
|
+
if self._fts_enabled:
|
|
1141
|
+
conn.execute(
|
|
1142
|
+
"INSERT INTO notes_fts(rowid, text) VALUES (?, ?)",
|
|
1143
|
+
(note_id, text.strip()),
|
|
1144
|
+
)
|
|
1145
|
+
row = conn.execute(
|
|
1146
|
+
"SELECT * FROM notes WHERE note_id = ?", (note_id,)
|
|
1147
|
+
).fetchone()
|
|
1148
|
+
return self._row_to_note(row) if row is not None else None
|
|
1149
|
+
|
|
1150
|
+
def deduplicate(self) -> int:
|
|
1151
|
+
conn = self._connection()
|
|
1152
|
+
with conn:
|
|
1153
|
+
rows = conn.execute(
|
|
1154
|
+
"SELECT * FROM notes ORDER BY note_id"
|
|
1155
|
+
).fetchall()
|
|
1156
|
+
seen: dict[str, MemoryNote] = {}
|
|
1157
|
+
ids_to_remove: list[int] = []
|
|
1158
|
+
for row in rows:
|
|
1159
|
+
note = self._row_to_note(row)
|
|
1160
|
+
text = note.text.strip().lower()
|
|
1161
|
+
if text in seen:
|
|
1162
|
+
existing = seen[text]
|
|
1163
|
+
existing.tags = sorted(set(existing.tags) | set(note.tags))
|
|
1164
|
+
if note.access_count:
|
|
1165
|
+
existing.access_count += note.access_count
|
|
1166
|
+
ids_to_remove.append(note.note_id)
|
|
1167
|
+
conn.execute(
|
|
1168
|
+
"UPDATE notes SET tags_json = ?, access_count = ? WHERE note_id = ?",
|
|
1169
|
+
(
|
|
1170
|
+
json.dumps(existing.tags),
|
|
1171
|
+
existing.access_count,
|
|
1172
|
+
existing.note_id,
|
|
1173
|
+
),
|
|
1174
|
+
)
|
|
1175
|
+
else:
|
|
1176
|
+
seen[text] = note
|
|
1177
|
+
if ids_to_remove:
|
|
1178
|
+
self._delete_by_ids(ids_to_remove)
|
|
1179
|
+
return len(ids_to_remove)
|
|
1180
|
+
|
|
1181
|
+
def export(self, destination: str | Path) -> Path:
|
|
1182
|
+
dest = Path(destination)
|
|
1183
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1184
|
+
lines: list[str] = ["# Memory Notes\n"]
|
|
1185
|
+
for note in self.all():
|
|
1186
|
+
tag_part = f" *[{', '.join(note.tags)}]*" if note.tags else ""
|
|
1187
|
+
access_part = (
|
|
1188
|
+
f", accessed {note.access_count} time(s)" if note.access_count else ""
|
|
1189
|
+
)
|
|
1190
|
+
lines.append(
|
|
1191
|
+
f"- {note.text}{tag_part} (id={note.note_id}, {note.created}{access_part})\n"
|
|
1192
|
+
)
|
|
1193
|
+
dest.write_text("".join(lines), encoding="utf-8")
|
|
1194
|
+
return dest
|
|
1195
|
+
|
|
1196
|
+
def close(self) -> None:
|
|
1197
|
+
if self._conn is not None:
|
|
1198
|
+
self._conn.close()
|
|
1199
|
+
self._conn = None
|
|
1200
|
+
|
|
1201
|
+
def _touch(self, note: MemoryNote) -> None:
|
|
1202
|
+
note.access_count += 1
|
|
1203
|
+
note.accessed = datetime.now(UTC).isoformat()
|
|
1204
|
+
with self._connection():
|
|
1205
|
+
self._connection().execute(
|
|
1206
|
+
"UPDATE notes SET access_count = ?, accessed = ? WHERE note_id = ?",
|
|
1207
|
+
(note.access_count, note.accessed, note.note_id),
|
|
1208
|
+
)
|