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.
Files changed (74) hide show
  1. agentkernel/__init__.py +7 -0
  2. agentkernel/__main__.py +5 -0
  3. agentkernel/agent.py +311 -0
  4. agentkernel/approval/__init__.py +23 -0
  5. agentkernel/approval/base.py +34 -0
  6. agentkernel/approval/cli.py +129 -0
  7. agentkernel/approval/policy.py +58 -0
  8. agentkernel/approval/risk.py +91 -0
  9. agentkernel/approval/sandbox.py +201 -0
  10. agentkernel/budget.py +64 -0
  11. agentkernel/checkpoint.py +50 -0
  12. agentkernel/cli.py +1482 -0
  13. agentkernel/config.py +224 -0
  14. agentkernel/context/__init__.py +17 -0
  15. agentkernel/context/manager.py +216 -0
  16. agentkernel/context/truncate.py +35 -0
  17. agentkernel/cron.py +146 -0
  18. agentkernel/curation.py +183 -0
  19. agentkernel/doctor.py +141 -0
  20. agentkernel/embeddings.py +132 -0
  21. agentkernel/evaluation.py +186 -0
  22. agentkernel/improvement.py +133 -0
  23. agentkernel/insights.py +141 -0
  24. agentkernel/kanban.py +114 -0
  25. agentkernel/knowledge.py +383 -0
  26. agentkernel/loops.py +145 -0
  27. agentkernel/mcp/__init__.py +23 -0
  28. agentkernel/mcp/client.py +181 -0
  29. agentkernel/mcp/config.py +59 -0
  30. agentkernel/mcp/tools.py +96 -0
  31. agentkernel/memory.py +1208 -0
  32. agentkernel/paths.py +73 -0
  33. agentkernel/plugins.py +76 -0
  34. agentkernel/profiles.py +70 -0
  35. agentkernel/progress.py +89 -0
  36. agentkernel/providers/__init__.py +35 -0
  37. agentkernel/providers/_http.py +157 -0
  38. agentkernel/providers/anthropic.py +282 -0
  39. agentkernel/providers/base.py +38 -0
  40. agentkernel/providers/credentials.py +65 -0
  41. agentkernel/providers/local.py +34 -0
  42. agentkernel/providers/openai.py +260 -0
  43. agentkernel/redaction.py +77 -0
  44. agentkernel/semantic_index.py +139 -0
  45. agentkernel/semantic_memory.py +253 -0
  46. agentkernel/skills.py +268 -0
  47. agentkernel/subagent.py +161 -0
  48. agentkernel/telemetry.py +199 -0
  49. agentkernel/templates/README.md +35 -0
  50. agentkernel/templates/SKILL.md +28 -0
  51. agentkernel/templates/eval-suite.toml +22 -0
  52. agentkernel/templates/loop.toml +29 -0
  53. agentkernel/templates/mcp-servers.toml +22 -0
  54. agentkernel/templates/profile.toml +29 -0
  55. agentkernel/templates/tool_module.py +64 -0
  56. agentkernel/tools/__init__.py +5 -0
  57. agentkernel/tools/base.py +100 -0
  58. agentkernel/tools/builtin/__init__.py +37 -0
  59. agentkernel/tools/builtin/checkpoint_tool.py +33 -0
  60. agentkernel/tools/builtin/clarify.py +60 -0
  61. agentkernel/tools/builtin/files.py +221 -0
  62. agentkernel/tools/builtin/kanban_tool.py +100 -0
  63. agentkernel/tools/builtin/search.py +225 -0
  64. agentkernel/tools/builtin/shell.py +67 -0
  65. agentkernel/tools/builtin/todo.py +106 -0
  66. agentkernel/tui/__init__.py +50 -0
  67. agentkernel/tui/app.py +594 -0
  68. agentkernel/types.py +127 -0
  69. agentkernel/worktree.py +64 -0
  70. agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
  71. agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
  72. agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
  73. agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
  74. 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
+ )