memorytalk 0.4.2__tar.gz → 0.5.0__tar.gz

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 (158) hide show
  1. {memorytalk-0.4.2 → memorytalk-0.5.0}/PKG-INFO +3 -2
  2. memorytalk-0.5.0/memorytalk/__init__.py +2 -0
  3. memorytalk-0.5.0/memorytalk/adapters/__init__.py +6 -0
  4. memorytalk-0.5.0/memorytalk/adapters/base.py +82 -0
  5. memorytalk-0.5.0/memorytalk/adapters/claude_code.py +236 -0
  6. memorytalk-0.5.0/memorytalk/api/__init__.py +140 -0
  7. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/api/cards.py +9 -5
  8. memorytalk-0.5.0/memorytalk/api/read.py +36 -0
  9. memorytalk-0.5.0/memorytalk/api/recall.py +26 -0
  10. memorytalk-0.5.0/memorytalk/api/reviews.py +26 -0
  11. memorytalk-0.5.0/memorytalk/api/search.py +28 -0
  12. memorytalk-0.5.0/memorytalk/api/sessions.py +52 -0
  13. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/api/status.py +9 -4
  14. memorytalk-0.5.0/memorytalk/api/sync.py +35 -0
  15. memorytalk-0.5.0/memorytalk/cli/__init__.py +27 -0
  16. memorytalk-0.5.0/memorytalk/cli/_format.py +402 -0
  17. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/cli/_http.py +26 -19
  18. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/cli/_render.py +16 -18
  19. memorytalk-0.5.0/memorytalk/cli/card.py +54 -0
  20. memorytalk-0.4.2/memorytalk/cli/view.py → memorytalk-0.5.0/memorytalk/cli/read.py +13 -8
  21. memorytalk-0.5.0/memorytalk/cli/recall.py +152 -0
  22. memorytalk-0.5.0/memorytalk/cli/review.py +48 -0
  23. memorytalk-0.5.0/memorytalk/cli/search.py +46 -0
  24. memorytalk-0.5.0/memorytalk/cli/server.py +206 -0
  25. memorytalk-0.5.0/memorytalk/cli/setup.py +292 -0
  26. memorytalk-0.5.0/memorytalk/cli/sync.py +43 -0
  27. memorytalk-0.5.0/memorytalk/config.py +241 -0
  28. memorytalk-0.5.0/memorytalk/provider/__init__.py +1 -0
  29. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/provider/embedding.py +20 -27
  30. memorytalk-0.5.0/memorytalk/provider/lancedb.py +240 -0
  31. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/provider/storage.py +13 -15
  32. memorytalk-0.5.0/memorytalk/repository/__init__.py +4 -0
  33. memorytalk-0.5.0/memorytalk/repository/cards.py +205 -0
  34. memorytalk-0.5.0/memorytalk/repository/recall.py +38 -0
  35. memorytalk-0.5.0/memorytalk/repository/reviews.py +50 -0
  36. memorytalk-0.5.0/memorytalk/repository/schema.py +151 -0
  37. memorytalk-0.5.0/memorytalk/repository/search_log.py +37 -0
  38. memorytalk-0.5.0/memorytalk/repository/sessions.py +137 -0
  39. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/repository/store.py +14 -18
  40. memorytalk-0.5.0/memorytalk/repository/sync_checkpoint.py +108 -0
  41. memorytalk-0.5.0/memorytalk/schemas/__init__.py +37 -0
  42. memorytalk-0.5.0/memorytalk/schemas/card.py +58 -0
  43. memorytalk-0.5.0/memorytalk/schemas/cards.py +40 -0
  44. memorytalk-0.5.0/memorytalk/schemas/read.py +28 -0
  45. memorytalk-0.5.0/memorytalk/schemas/recall.py +33 -0
  46. memorytalk-0.5.0/memorytalk/schemas/review.py +21 -0
  47. memorytalk-0.5.0/memorytalk/schemas/reviews.py +28 -0
  48. memorytalk-0.5.0/memorytalk/schemas/search.py +55 -0
  49. memorytalk-0.5.0/memorytalk/schemas/session.py +133 -0
  50. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/schemas/status.py +8 -4
  51. memorytalk-0.5.0/memorytalk/schemas/sync.py +60 -0
  52. memorytalk-0.5.0/memorytalk/server.py +119 -0
  53. memorytalk-0.5.0/memorytalk/service/__init__.py +23 -0
  54. memorytalk-0.5.0/memorytalk/service/cards.py +202 -0
  55. memorytalk-0.5.0/memorytalk/service/events.py +34 -0
  56. memorytalk-0.5.0/memorytalk/service/read.py +105 -0
  57. memorytalk-0.5.0/memorytalk/service/recall.py +123 -0
  58. memorytalk-0.5.0/memorytalk/service/reviews.py +110 -0
  59. memorytalk-0.5.0/memorytalk/service/search.py +373 -0
  60. memorytalk-0.5.0/memorytalk/service/sessions.py +266 -0
  61. memorytalk-0.5.0/memorytalk/service/sync.py +505 -0
  62. memorytalk-0.5.0/memorytalk/util/__init__.py +4 -0
  63. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/util/console.py +11 -39
  64. memorytalk-0.5.0/memorytalk/util/dsl.py +285 -0
  65. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/util/env_template.py +11 -16
  66. memorytalk-0.5.0/memorytalk/util/formula.py +122 -0
  67. memorytalk-0.5.0/memorytalk/util/highlight.py +117 -0
  68. memorytalk-0.5.0/memorytalk/util/ids.py +65 -0
  69. memorytalk-0.5.0/memorytalk/util/indexes.py +48 -0
  70. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/util/settings_io.py +5 -6
  71. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/PKG-INFO +3 -2
  72. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/SOURCES.txt +20 -40
  73. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/requires.txt +1 -0
  74. {memorytalk-0.4.2 → memorytalk-0.5.0}/pyproject.toml +3 -8
  75. memorytalk-0.4.2/memorytalk/__init__.py +0 -0
  76. memorytalk-0.4.2/memorytalk/adapters/__init__.py +0 -4
  77. memorytalk-0.4.2/memorytalk/adapters/base.py +0 -33
  78. memorytalk-0.4.2/memorytalk/adapters/claude_code.py +0 -119
  79. memorytalk-0.4.2/memorytalk/api/__init__.py +0 -107
  80. memorytalk-0.4.2/memorytalk/api/links.py +0 -20
  81. memorytalk-0.4.2/memorytalk/api/log.py +0 -33
  82. memorytalk-0.4.2/memorytalk/api/rebuild.py +0 -21
  83. memorytalk-0.4.2/memorytalk/api/recall.py +0 -18
  84. memorytalk-0.4.2/memorytalk/api/review.py +0 -36
  85. memorytalk-0.4.2/memorytalk/api/search.py +0 -18
  86. memorytalk-0.4.2/memorytalk/api/sessions.py +0 -18
  87. memorytalk-0.4.2/memorytalk/api/tags.py +0 -71
  88. memorytalk-0.4.2/memorytalk/api/view.py +0 -33
  89. memorytalk-0.4.2/memorytalk/cli/__init__.py +0 -24
  90. memorytalk-0.4.2/memorytalk/cli/_format.py +0 -540
  91. memorytalk-0.4.2/memorytalk/cli/card.py +0 -41
  92. memorytalk-0.4.2/memorytalk/cli/filter.py +0 -402
  93. memorytalk-0.4.2/memorytalk/cli/link.py +0 -45
  94. memorytalk-0.4.2/memorytalk/cli/log.py +0 -32
  95. memorytalk-0.4.2/memorytalk/cli/rebuild.py +0 -31
  96. memorytalk-0.4.2/memorytalk/cli/recall.py +0 -122
  97. memorytalk-0.4.2/memorytalk/cli/review.py +0 -63
  98. memorytalk-0.4.2/memorytalk/cli/search.py +0 -40
  99. memorytalk-0.4.2/memorytalk/cli/server.py +0 -146
  100. memorytalk-0.4.2/memorytalk/cli/setup/__init__.py +0 -146
  101. memorytalk-0.4.2/memorytalk/cli/setup/steps/__init__.py +0 -0
  102. memorytalk-0.4.2/memorytalk/cli/setup/steps/claude_hook.py +0 -138
  103. memorytalk-0.4.2/memorytalk/cli/setup/steps/embedding.py +0 -200
  104. memorytalk-0.4.2/memorytalk/cli/setup/steps/path_takeover.py +0 -208
  105. memorytalk-0.4.2/memorytalk/cli/setup/steps/provider.py +0 -18
  106. memorytalk-0.4.2/memorytalk/cli/setup/steps/server.py +0 -71
  107. memorytalk-0.4.2/memorytalk/cli/setup/summary.py +0 -128
  108. memorytalk-0.4.2/memorytalk/cli/setup/venv.py +0 -117
  109. memorytalk-0.4.2/memorytalk/cli/setup/wizard.py +0 -117
  110. memorytalk-0.4.2/memorytalk/cli/sync.py +0 -57
  111. memorytalk-0.4.2/memorytalk/cli/tag.py +0 -88
  112. memorytalk-0.4.2/memorytalk/config.py +0 -183
  113. memorytalk-0.4.2/memorytalk/filters/__init__.py +0 -0
  114. memorytalk-0.4.2/memorytalk/filters/new-session/filter.py +0 -28
  115. memorytalk-0.4.2/memorytalk/filters/new-session/meta.json +0 -5
  116. memorytalk-0.4.2/memorytalk/provider/__init__.py +0 -0
  117. memorytalk-0.4.2/memorytalk/provider/lancedb.py +0 -182
  118. memorytalk-0.4.2/memorytalk/repository/__init__.py +0 -21
  119. memorytalk-0.4.2/memorytalk/repository/cards.py +0 -124
  120. memorytalk-0.4.2/memorytalk/repository/links.py +0 -101
  121. memorytalk-0.4.2/memorytalk/repository/recall.py +0 -255
  122. memorytalk-0.4.2/memorytalk/repository/schema.py +0 -114
  123. memorytalk-0.4.2/memorytalk/repository/search_log.py +0 -105
  124. memorytalk-0.4.2/memorytalk/repository/sessions.py +0 -230
  125. memorytalk-0.4.2/memorytalk/repository/tags.py +0 -134
  126. memorytalk-0.4.2/memorytalk/schemas/__init__.py +0 -63
  127. memorytalk-0.4.2/memorytalk/schemas/cards.py +0 -21
  128. memorytalk-0.4.2/memorytalk/schemas/links.py +0 -20
  129. memorytalk-0.4.2/memorytalk/schemas/log.py +0 -22
  130. memorytalk-0.4.2/memorytalk/schemas/rebuild.py +0 -12
  131. memorytalk-0.4.2/memorytalk/schemas/recall.py +0 -23
  132. memorytalk-0.4.2/memorytalk/schemas/review.py +0 -42
  133. memorytalk-0.4.2/memorytalk/schemas/search.py +0 -45
  134. memorytalk-0.4.2/memorytalk/schemas/sessions.py +0 -37
  135. memorytalk-0.4.2/memorytalk/schemas/shared.py +0 -48
  136. memorytalk-0.4.2/memorytalk/schemas/tags.py +0 -26
  137. memorytalk-0.4.2/memorytalk/schemas/view.py +0 -38
  138. memorytalk-0.4.2/memorytalk/service/__init__.py +0 -29
  139. memorytalk-0.4.2/memorytalk/service/cards.py +0 -267
  140. memorytalk-0.4.2/memorytalk/service/events.py +0 -34
  141. memorytalk-0.4.2/memorytalk/service/links.py +0 -128
  142. memorytalk-0.4.2/memorytalk/service/rebuild.py +0 -168
  143. memorytalk-0.4.2/memorytalk/service/recall.py +0 -125
  144. memorytalk-0.4.2/memorytalk/service/search.py +0 -186
  145. memorytalk-0.4.2/memorytalk/service/sessions.py +0 -289
  146. memorytalk-0.4.2/memorytalk/service/tags.py +0 -162
  147. memorytalk-0.4.2/memorytalk/util/__init__.py +0 -0
  148. memorytalk-0.4.2/memorytalk/util/dsl.py +0 -338
  149. memorytalk-0.4.2/memorytalk/util/ids.py +0 -66
  150. memorytalk-0.4.2/memorytalk/util/snippet.py +0 -82
  151. memorytalk-0.4.2/memorytalk/util/ttl.py +0 -60
  152. {memorytalk-0.4.2 → memorytalk-0.5.0}/LICENSE +0 -0
  153. {memorytalk-0.4.2 → memorytalk-0.5.0}/README.md +0 -0
  154. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/__main__.py +0 -0
  155. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/dependency_links.txt +0 -0
  156. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/entry_points.txt +0 -0
  157. {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/top_level.txt +0 -0
  158. {memorytalk-0.4.2 → memorytalk-0.5.0}/setup.cfg +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memorytalk
3
- Version: 0.4.2
4
- Summary: Persistent cross-session memory for AI agents via Talk-Card architecture (v2)
3
+ Version: 0.5.0
4
+ Summary: Persistent cross-session memory for AI agents Talk-Card architecture with forum-dynamics sinking/floating (v3)
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
@@ -20,6 +20,7 @@ Requires-Dist: aiosqlite>=0.19.0
20
20
  Requires-Dist: aiofiles>=23.0.0
21
21
  Requires-Dist: rich>=13.0.0
22
22
  Requires-Dist: questionary>=2.0.0
23
+ Requires-Dist: watchdog>=4.0.0
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: pytest>=7.4.0; extra == "dev"
25
26
  Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
@@ -0,0 +1,2 @@
1
+ """memory-talk v3 — Talk-Card memory system with forum dynamics."""
2
+ __version__ = "0.5.0"
@@ -0,0 +1,6 @@
1
+ """Source-platform adapters — produce ingest payloads from local files."""
2
+ from memorytalk.adapters.base import ADAPTERS, BaseAdapter, get_adapter, register
3
+ # Import for side-effect: registers the claude-code adapter into ADAPTERS.
4
+ from memorytalk.adapters import claude_code # noqa: F401
5
+
6
+ __all__ = ["ADAPTERS", "BaseAdapter", "get_adapter", "register"]
@@ -0,0 +1,82 @@
1
+ """Base adapter — declares "where conversations come from" without saying
2
+ "how sync drives them".
3
+
4
+ An adapter is the in-process port for one upstream platform (Claude Code
5
+ on disk, a Codex API, …). It exposes three things to ``SyncWatcher``:
6
+
7
+ - ``watch_roots()`` — directories the watchdog should observe
8
+ (file-source-only; remote adapters return [])
9
+ - ``list_sources()`` — yield every ``SourceProbe`` known right now,
10
+ used by the cold-scan / backfill loop
11
+ - ``probe(source_id)`` — cheap inspection of one artifact
12
+ - ``read_after(...)`` — pull rounds strictly after a cursor
13
+
14
+ Sync owns the cursor state (sha + last_round_id + line_offset) in its
15
+ own ``sync.db``. Adapters are stateless ports.
16
+ """
17
+ from __future__ import annotations
18
+ from abc import ABC, abstractmethod
19
+ from pathlib import Path
20
+ from typing import Iterator
21
+
22
+ from memorytalk.schemas import ReadAfterResult, SourceProbe
23
+
24
+
25
+ class BaseAdapter(ABC):
26
+ source_name: str
27
+
28
+ @abstractmethod
29
+ def watch_roots(self) -> list[Path]:
30
+ """Filesystem directories the sync watchdog should observe.
31
+ Return ``[]`` for remote / non-filesystem adapters."""
32
+
33
+ @abstractmethod
34
+ def list_sources(self) -> Iterator[SourceProbe]:
35
+ """Enumerate every upstream artifact this adapter currently
36
+ knows about. Yields one ``SourceProbe`` per session.
37
+
38
+ Sync uses this on backfill to walk the entire upstream surface;
39
+ watchdog events bypass it and call ``probe`` on a single id.
40
+ """
41
+
42
+ @abstractmethod
43
+ def probe(self, source_id: str) -> SourceProbe | None:
44
+ """Inspect a single source artifact by its adapter-side id.
45
+
46
+ Returns ``None`` if the artifact no longer exists or isn't a
47
+ recognized session. The watcher calls this after debouncing a
48
+ file event."""
49
+
50
+ @abstractmethod
51
+ def read_after(
52
+ self,
53
+ source_id: str,
54
+ after_round_id: str | None,
55
+ hint_line_offset: int = 0,
56
+ ) -> ReadAfterResult:
57
+ """Read rounds strictly after ``after_round_id``.
58
+
59
+ ``hint_line_offset`` is the sync-side cached cursor offset.
60
+ Adapters that can validate it (e.g. by parsing the next record
61
+ at that offset and confirming its round_id == after_round_id)
62
+ SHOULD use it as a fast-seek hint; if validation fails they
63
+ MUST fall back to scanning from the start.
64
+
65
+ ``after_round_id=None`` means "read from the very beginning"
66
+ — used on first ingest of a previously-unseen session.
67
+ """
68
+
69
+
70
+ ADAPTERS: dict[str, type[BaseAdapter]] = {}
71
+
72
+
73
+ def register(cls: type[BaseAdapter]) -> type[BaseAdapter]:
74
+ ADAPTERS[cls.source_name] = cls
75
+ return cls
76
+
77
+
78
+ def get_adapter(name: str) -> BaseAdapter:
79
+ cls = ADAPTERS.get(name)
80
+ if not cls:
81
+ raise ValueError(f"unknown adapter: {name}")
82
+ return cls()
@@ -0,0 +1,236 @@
1
+ """Claude Code adapter.
2
+
3
+ Reads ``~/.claude/projects/*/*.jsonl`` conversation files and produces
4
+ ingest payloads. Each ``.jsonl`` file is one session; each line is one
5
+ platform message; ``round_id = uuid`` from the message.
6
+
7
+ Round-level layout:
8
+
9
+ - ``content`` block preservation matches the v3 ContentBlock schema —
10
+ tool_use / tool_result remain typed so future search / display can
11
+ render them with their original semantics (text projection is built
12
+ separately for FTS).
13
+ - ``metadata.cwd`` is extracted from the first message that carries
14
+ a ``cwd`` field so the explore namespace check works without the
15
+ server having to dig into each round.
16
+
17
+ Cursor semantics:
18
+
19
+ - ``source_id`` = absolute file path string.
20
+ - ``sha256`` = sha256 of the file's raw bytes (used by sync to
21
+ short-circuit "did anything change").
22
+ - ``after_round_id`` is the platform uuid of the last round the server
23
+ already stored. ``hint_line_offset`` is the line number sync cached
24
+ the last time it read this file — we use it as a fast-seek hint
25
+ but always validate before trusting it.
26
+ """
27
+ from __future__ import annotations
28
+ import hashlib
29
+ import json
30
+ from pathlib import Path
31
+ from typing import Iterator
32
+ from urllib.parse import unquote
33
+
34
+ from memorytalk.adapters.base import BaseAdapter, register
35
+ from memorytalk.schemas import (
36
+ ContentBlock, ReadAfterResult, RoundInput, SourceProbe,
37
+ )
38
+
39
+
40
+ @register
41
+ class ClaudeCodeAdapter(BaseAdapter):
42
+ source_name = "claude-code"
43
+
44
+ DEFAULT_ROOT = Path.home() / ".claude" / "projects"
45
+
46
+ # ────────── sync-facing surface ──────────
47
+
48
+ def watch_roots(self) -> list[Path]:
49
+ return [self.DEFAULT_ROOT]
50
+
51
+ def list_sources(self) -> Iterator[SourceProbe]:
52
+ if not self.DEFAULT_ROOT.exists():
53
+ return
54
+ for path in sorted(self.DEFAULT_ROOT.rglob("*.jsonl")):
55
+ probe = self.probe(str(path))
56
+ if probe is not None:
57
+ yield probe
58
+
59
+ def probe(self, source_id: str) -> SourceProbe | None:
60
+ path = Path(source_id)
61
+ if path.suffix != ".jsonl":
62
+ return None
63
+ try:
64
+ raw_bytes = path.read_bytes()
65
+ except FileNotFoundError:
66
+ return None
67
+ if not raw_bytes:
68
+ return None
69
+ sha256 = hashlib.sha256(raw_bytes).hexdigest()
70
+ session_id = path.stem # platform uuid, no prefix
71
+ project_name = unquote(path.parent.name)
72
+
73
+ # Pull created_at + cwd from the first parseable message.
74
+ created_at: str | None = None
75
+ cwd: str | None = None
76
+ for line in raw_bytes.decode("utf-8", errors="replace").splitlines():
77
+ line = line.strip()
78
+ if not line:
79
+ continue
80
+ try:
81
+ msg = json.loads(line)
82
+ except json.JSONDecodeError:
83
+ continue
84
+ if created_at is None and msg.get("timestamp"):
85
+ created_at = msg["timestamp"]
86
+ if cwd is None and msg.get("cwd"):
87
+ cwd = msg["cwd"]
88
+ if created_at is not None and cwd is not None:
89
+ break
90
+
91
+ metadata: dict = {"project": project_name, "path": str(path)}
92
+ if cwd:
93
+ metadata["cwd"] = cwd
94
+
95
+ return SourceProbe(
96
+ source_id=str(path),
97
+ session_id=session_id,
98
+ sha256=sha256,
99
+ created_at=created_at or "",
100
+ metadata=metadata,
101
+ )
102
+
103
+ def read_after(
104
+ self,
105
+ source_id: str,
106
+ after_round_id: str | None,
107
+ hint_line_offset: int = 0,
108
+ ) -> ReadAfterResult:
109
+ path = Path(source_id)
110
+ if path.suffix != ".jsonl":
111
+ return ReadAfterResult(rounds=[], next_line_offset=0)
112
+ try:
113
+ raw = path.read_text(encoding="utf-8", errors="replace")
114
+ except FileNotFoundError:
115
+ return ReadAfterResult(rounds=[], next_line_offset=0)
116
+ lines = raw.splitlines()
117
+
118
+ start_line = self._locate_start(lines, after_round_id, hint_line_offset)
119
+
120
+ rounds: list[RoundInput] = []
121
+ cursor = start_line
122
+ for cursor in range(start_line, len(lines)):
123
+ line = lines[cursor].strip()
124
+ if not line:
125
+ continue
126
+ try:
127
+ msg = json.loads(line)
128
+ except json.JSONDecodeError:
129
+ continue
130
+ parsed = self._parse_message(msg)
131
+ if parsed is None:
132
+ continue
133
+ rounds.append(parsed)
134
+
135
+ return ReadAfterResult(rounds=rounds, next_line_offset=len(lines))
136
+
137
+ # ────────── seek / parse helpers ──────────
138
+
139
+ def _locate_start(
140
+ self,
141
+ lines: list[str],
142
+ after_round_id: str | None,
143
+ hint_line_offset: int,
144
+ ) -> int:
145
+ """Return the index of the first line whose round should be
146
+ yielded (i.e., the line strictly after the one carrying
147
+ ``after_round_id``).
148
+
149
+ Strategy:
150
+ - If ``after_round_id`` is None → start at 0 (full read).
151
+ - Otherwise: trust ``hint_line_offset`` only if line[hint-1]
152
+ actually carries ``after_round_id``. If it doesn't (file got
153
+ rewritten, hint stale, ...), scan from the top to find the
154
+ marker. If marker not found anywhere → start at 0.
155
+ """
156
+ if after_round_id is None:
157
+ return 0
158
+
159
+ if 0 < hint_line_offset <= len(lines):
160
+ if self._line_round_id(lines[hint_line_offset - 1]) == after_round_id:
161
+ return hint_line_offset
162
+
163
+ for i, line in enumerate(lines):
164
+ if self._line_round_id(line) == after_round_id:
165
+ return i + 1
166
+ # Marker not in the file at all — caller has a stale cursor.
167
+ # Treat as fresh ingest so the conflict-retry path kicks in.
168
+ return 0
169
+
170
+ @staticmethod
171
+ def _line_round_id(line: str) -> str | None:
172
+ line = line.strip()
173
+ if not line:
174
+ return None
175
+ try:
176
+ msg = json.loads(line)
177
+ except json.JSONDecodeError:
178
+ return None
179
+ return msg.get("uuid") or None
180
+
181
+ # ────────── per-message parsing ──────────
182
+
183
+ def _parse_message(self, msg: dict) -> RoundInput | None:
184
+ msg_type = msg.get("type")
185
+ if msg_type not in ("user", "assistant"):
186
+ return None
187
+ role = "human" if msg_type == "user" else "assistant"
188
+ speaker = "user" if msg_type == "user" else "assistant"
189
+ raw_content = msg.get("message", {}).get("content", [])
190
+ blocks = self._parse_content(raw_content)
191
+ if not blocks:
192
+ if isinstance(raw_content, str):
193
+ blocks = [{"type": "text", "text": raw_content}]
194
+ else:
195
+ return None
196
+ return RoundInput(
197
+ round_id=msg.get("uuid", ""),
198
+ parent_id=msg.get("parentUuid"),
199
+ timestamp=msg.get("timestamp"),
200
+ speaker=speaker,
201
+ role=role,
202
+ content=[ContentBlock(**b) for b in blocks],
203
+ is_sidechain=bool(msg.get("isSidechain")),
204
+ cwd=msg.get("cwd"),
205
+ )
206
+
207
+ def _parse_content(self, content_list) -> list[dict]:
208
+ if not isinstance(content_list, list):
209
+ return []
210
+ out: list[dict] = []
211
+ for block in content_list:
212
+ if not isinstance(block, dict):
213
+ continue
214
+ t = block.get("type")
215
+ if t == "text":
216
+ text = block.get("text") or ""
217
+ if text:
218
+ out.append({"type": "text", "text": text})
219
+ elif t == "thinking":
220
+ thinking = block.get("thinking") or ""
221
+ if thinking:
222
+ out.append({"type": "thinking", "thinking": thinking})
223
+ elif t == "tool_use":
224
+ name = block.get("name", "tool")
225
+ inp = block.get("input", "")
226
+ if isinstance(inp, dict):
227
+ inp = json.dumps(inp, ensure_ascii=False)
228
+ # Keep typed; carry text for FTS.
229
+ out.append({"type": "tool_use", "name": name, "input": inp,
230
+ "text": f"[{name}] {inp}"})
231
+ elif t == "tool_result":
232
+ c = block.get("content", "")
233
+ if isinstance(c, list):
234
+ c = json.dumps(c, ensure_ascii=False)
235
+ out.append({"type": "tool_result", "text": str(c)})
236
+ return out
@@ -0,0 +1,140 @@
1
+ """FastAPI app factory for v3.
2
+
3
+ Async startup via lifespan: open SQLite + LanceDB, probe the embedding
4
+ provider, wire services into ``app.state``.
5
+
6
+ Optional services that depend on optional providers (vector store) are
7
+ constructed defensively — if a dep is unavailable the app still starts
8
+ and the dependent endpoint returns 503 ``unavailable`` (rather than the
9
+ whole server failing to come up).
10
+ """
11
+ from __future__ import annotations
12
+ import logging
13
+ import os
14
+ from contextlib import asynccontextmanager
15
+ from pathlib import Path
16
+
17
+ from fastapi import FastAPI
18
+
19
+ from memorytalk.config import Config, ConfigValidationError
20
+
21
+
22
+ _log = logging.getLogger("memorytalk.api")
23
+ from memorytalk.provider.embedding import (
24
+ EmbedderValidationError, get_embedder, validate_embedder,
25
+ )
26
+ from memorytalk.provider.lancedb import LanceStore
27
+ from memorytalk.provider.storage import LocalStorage
28
+ from memorytalk.repository import SQLiteStore
29
+ from memorytalk.repository.sync_checkpoint import SyncCheckpointStore
30
+ from memorytalk.service import (
31
+ CardService, EventWriter, IngestService, ReadService,
32
+ RecallService, ReviewService,
33
+ )
34
+ from memorytalk.service.search import SearchService
35
+ from memorytalk.service.sync import SyncWatcher
36
+
37
+
38
+ def create_app(config: Config | None = None) -> FastAPI:
39
+ config = config or Config()
40
+ config.ensure_dirs()
41
+
42
+ @asynccontextmanager
43
+ async def lifespan(app: FastAPI):
44
+ storage = LocalStorage(config.data_root)
45
+ db = await SQLiteStore.create(config.db_path, storage)
46
+ sync_checkpoints = await SyncCheckpointStore.create(config.sync_db_path)
47
+
48
+ # LanceDB is optional at boot. If it can't open (missing pyarrow,
49
+ # bad dir perms, ...) we still want read/status to work.
50
+ vectors: LanceStore | None = None
51
+ try:
52
+ vectors = await LanceStore.create(
53
+ config.vectors_dir, dim=config.settings.embedding.dim,
54
+ )
55
+ except Exception:
56
+ _log.exception("lancedb init failed; vector-backed endpoints will 503")
57
+
58
+ embedder = get_embedder(config)
59
+
60
+ try:
61
+ await validate_embedder(config)
62
+ except EmbedderValidationError as e:
63
+ _log.exception("embedding startup check failed; aborting boot")
64
+ raise SystemExit(2) from e
65
+
66
+ events = EventWriter(db)
67
+ app.state.config = config
68
+ app.state.storage = storage
69
+ app.state.db = db
70
+ app.state.vectors = vectors
71
+ app.state.embedder = embedder
72
+ app.state.events = events
73
+ app.state.read = ReadService(db=db, events=events)
74
+ app.state.ingest = IngestService(
75
+ db=db, vectors=vectors, embedder=embedder, events=events,
76
+ )
77
+ app.state.sync_checkpoints = sync_checkpoints
78
+ app.state.sync = SyncWatcher(
79
+ config=config, ingest=app.state.ingest,
80
+ checkpoints=sync_checkpoints,
81
+ )
82
+ app.state.search = SearchService(
83
+ config=config, db=db, vectors=vectors, embedder=embedder,
84
+ )
85
+ app.state.cards = CardService(
86
+ db=db, vectors=vectors, embedder=embedder, events=events,
87
+ )
88
+ app.state.reviews = ReviewService(db=db, events=events)
89
+ app.state.recall = RecallService(
90
+ config=config, db=db, vectors=vectors, embedder=embedder,
91
+ )
92
+
93
+ # Spin up the watcher if settings says so. start() returns fast
94
+ # now — backfill runs as a background task; uvicorn's "startup
95
+ # complete" log is no longer gated on the initial ingest.
96
+ if config.settings.sync.enabled:
97
+ try:
98
+ await app.state.sync.start()
99
+ except Exception:
100
+ _log.exception("sync auto-start failed")
101
+
102
+ yield
103
+
104
+ # Pause (not stop) on shutdown — preserves the user's persisted
105
+ # enable choice so the next server start auto-resumes the watcher.
106
+ try:
107
+ await app.state.sync.pause()
108
+ except Exception:
109
+ pass
110
+ await db.close()
111
+ await sync_checkpoints.close()
112
+
113
+ app = FastAPI(title="memory.talk v3", lifespan=lifespan)
114
+ app.state.config = config
115
+
116
+ # Mount the v3 routers. Each module exports a ``router``; missing
117
+ # modules are silently skipped so partial-build environments still
118
+ # come up (useful during incremental implementation).
119
+ from memorytalk.api.status import router as status_router
120
+ app.include_router(status_router, prefix="/v3")
121
+ from memorytalk.api.read import router as read_router
122
+ app.include_router(read_router, prefix="/v3")
123
+ from memorytalk.api.sessions import router as sessions_router
124
+ app.include_router(sessions_router, prefix="/v3")
125
+
126
+ # Optional routers — lazy import so missing ones don't break boot.
127
+ for name in ("sync", "search", "cards", "reviews", "recall"):
128
+ try:
129
+ mod = __import__(f"memorytalk.api.{name}", fromlist=["router"])
130
+ app.include_router(mod.router, prefix="/v3")
131
+ except ImportError:
132
+ pass
133
+
134
+ return app
135
+
136
+
137
+ # uvicorn entry point. The data_root env var is the same test hook
138
+ # `Config.__init__` honors; user-facing CLI hardcodes ~/.memory-talk.
139
+ _data_root = os.environ.get("MEMORY_TALK_DATA_ROOT")
140
+ app = create_app(Config(_data_root) if _data_root else Config())
@@ -1,20 +1,24 @@
1
- """POST /v2/cards."""
1
+ """POST /v3/cards."""
2
2
  from __future__ import annotations
3
3
 
4
4
  from fastapi import APIRouter, HTTPException, Request
5
5
 
6
6
  from memorytalk.schemas import CreateCardRequest, CreateCardResponse
7
- from memorytalk.service import CardConflictError, CardServiceError
7
+ from memorytalk.service import CardConflict, CardServiceError
8
8
 
9
9
 
10
10
  router = APIRouter()
11
11
 
12
12
 
13
13
  @router.post("/cards", response_model=CreateCardResponse)
14
- async def post_cards(payload: CreateCardRequest, request: Request):
14
+ async def post_cards(payload: CreateCardRequest, request: Request) -> CreateCardResponse:
15
+ svc = request.app.state.cards
16
+ if svc is None:
17
+ raise HTTPException(status_code=503, detail="cards service unavailable")
15
18
  try:
16
- return await request.app.state.cards.create(payload)
17
- except CardConflictError as e:
19
+ card_id = await svc.create(payload)
20
+ except CardConflict as e:
18
21
  raise HTTPException(status_code=409, detail=str(e))
19
22
  except CardServiceError as e:
20
23
  raise HTTPException(status_code=400, detail=str(e))
24
+ return CreateCardResponse(card_id=card_id)
@@ -0,0 +1,36 @@
1
+ """POST /v3/read — prefix-dispatched read of card or session."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import ReadRequest
7
+ from memorytalk.service import CardNotFound, SessionNotFound
8
+ from memorytalk.util.ids import IdKind, InvalidIdError, parse_id
9
+
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("/read")
15
+ async def post_read(payload: ReadRequest, request: Request):
16
+ try:
17
+ kind, _ = parse_id(payload.id)
18
+ except InvalidIdError:
19
+ raise HTTPException(status_code=400, detail="invalid id prefix")
20
+
21
+ svc = request.app.state.read
22
+
23
+ try:
24
+ if kind == IdKind.CARD:
25
+ card, read_at = await svc.read_card(payload.id)
26
+ return {"type": "card", "read_at": read_at, "card": card.model_dump()}
27
+ if kind == IdKind.SESSION:
28
+ session, read_at = await svc.read_session(payload.id)
29
+ return {"type": "session", "read_at": read_at,
30
+ "session": session.model_dump()}
31
+ except CardNotFound as e:
32
+ raise HTTPException(status_code=404, detail=str(e))
33
+ except SessionNotFound as e:
34
+ raise HTTPException(status_code=404, detail=str(e))
35
+
36
+ raise HTTPException(status_code=400, detail="invalid id prefix")
@@ -0,0 +1,26 @@
1
+ """POST /v3/recall."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import RecallRequest, RecallResponse
7
+ from memorytalk.service import RecallServiceError
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/recall", response_model=RecallResponse)
14
+ async def post_recall(payload: RecallRequest, request: Request) -> RecallResponse:
15
+ svc = request.app.state.recall
16
+ if svc is None:
17
+ raise HTTPException(status_code=503, detail="recall service unavailable")
18
+ try:
19
+ result = await svc.recall(
20
+ session_id=payload.session_id,
21
+ prompt=payload.prompt,
22
+ top_k=payload.top_k,
23
+ )
24
+ except RecallServiceError as e:
25
+ raise HTTPException(status_code=400, detail=str(e))
26
+ return RecallResponse(**result)
@@ -0,0 +1,26 @@
1
+ """POST /v3/reviews."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import CreateReviewRequest, CreateReviewResponse
7
+ from memorytalk.service import ReviewConflict, ReviewServiceError
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/reviews", response_model=CreateReviewResponse)
14
+ async def post_reviews(
15
+ payload: CreateReviewRequest, request: Request,
16
+ ) -> CreateReviewResponse:
17
+ svc = request.app.state.reviews
18
+ if svc is None:
19
+ raise HTTPException(status_code=503, detail="reviews service unavailable")
20
+ try:
21
+ result = await svc.create(payload)
22
+ except ReviewConflict as e:
23
+ raise HTTPException(status_code=409, detail=str(e))
24
+ except ReviewServiceError as e:
25
+ raise HTTPException(status_code=400, detail=str(e))
26
+ return CreateReviewResponse(**result)
@@ -0,0 +1,28 @@
1
+ """POST /v3/search."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import SearchRequest, SearchResponse
7
+ from memorytalk.util.dsl import DSLError
8
+ from memorytalk.util.formula import FormulaError
9
+
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.post("/search", response_model=SearchResponse)
15
+ async def post_search(payload: SearchRequest, request: Request) -> SearchResponse:
16
+ svc = request.app.state.search
17
+ if svc is None:
18
+ raise HTTPException(status_code=503, detail="search service unavailable")
19
+ try:
20
+ return await svc.search(
21
+ query=payload.query or "",
22
+ where=payload.where,
23
+ top_k=payload.top_k,
24
+ )
25
+ except DSLError as e:
26
+ raise HTTPException(status_code=400, detail=str(e))
27
+ except FormulaError as e:
28
+ raise HTTPException(status_code=500, detail=str(e))