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.
- {memorytalk-0.4.2 → memorytalk-0.5.0}/PKG-INFO +3 -2
- memorytalk-0.5.0/memorytalk/__init__.py +2 -0
- memorytalk-0.5.0/memorytalk/adapters/__init__.py +6 -0
- memorytalk-0.5.0/memorytalk/adapters/base.py +82 -0
- memorytalk-0.5.0/memorytalk/adapters/claude_code.py +236 -0
- memorytalk-0.5.0/memorytalk/api/__init__.py +140 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/api/cards.py +9 -5
- memorytalk-0.5.0/memorytalk/api/read.py +36 -0
- memorytalk-0.5.0/memorytalk/api/recall.py +26 -0
- memorytalk-0.5.0/memorytalk/api/reviews.py +26 -0
- memorytalk-0.5.0/memorytalk/api/search.py +28 -0
- memorytalk-0.5.0/memorytalk/api/sessions.py +52 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/api/status.py +9 -4
- memorytalk-0.5.0/memorytalk/api/sync.py +35 -0
- memorytalk-0.5.0/memorytalk/cli/__init__.py +27 -0
- memorytalk-0.5.0/memorytalk/cli/_format.py +402 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/cli/_http.py +26 -19
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/cli/_render.py +16 -18
- memorytalk-0.5.0/memorytalk/cli/card.py +54 -0
- memorytalk-0.4.2/memorytalk/cli/view.py → memorytalk-0.5.0/memorytalk/cli/read.py +13 -8
- memorytalk-0.5.0/memorytalk/cli/recall.py +152 -0
- memorytalk-0.5.0/memorytalk/cli/review.py +48 -0
- memorytalk-0.5.0/memorytalk/cli/search.py +46 -0
- memorytalk-0.5.0/memorytalk/cli/server.py +206 -0
- memorytalk-0.5.0/memorytalk/cli/setup.py +292 -0
- memorytalk-0.5.0/memorytalk/cli/sync.py +43 -0
- memorytalk-0.5.0/memorytalk/config.py +241 -0
- memorytalk-0.5.0/memorytalk/provider/__init__.py +1 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/provider/embedding.py +20 -27
- memorytalk-0.5.0/memorytalk/provider/lancedb.py +240 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/provider/storage.py +13 -15
- memorytalk-0.5.0/memorytalk/repository/__init__.py +4 -0
- memorytalk-0.5.0/memorytalk/repository/cards.py +205 -0
- memorytalk-0.5.0/memorytalk/repository/recall.py +38 -0
- memorytalk-0.5.0/memorytalk/repository/reviews.py +50 -0
- memorytalk-0.5.0/memorytalk/repository/schema.py +151 -0
- memorytalk-0.5.0/memorytalk/repository/search_log.py +37 -0
- memorytalk-0.5.0/memorytalk/repository/sessions.py +137 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/repository/store.py +14 -18
- memorytalk-0.5.0/memorytalk/repository/sync_checkpoint.py +108 -0
- memorytalk-0.5.0/memorytalk/schemas/__init__.py +37 -0
- memorytalk-0.5.0/memorytalk/schemas/card.py +58 -0
- memorytalk-0.5.0/memorytalk/schemas/cards.py +40 -0
- memorytalk-0.5.0/memorytalk/schemas/read.py +28 -0
- memorytalk-0.5.0/memorytalk/schemas/recall.py +33 -0
- memorytalk-0.5.0/memorytalk/schemas/review.py +21 -0
- memorytalk-0.5.0/memorytalk/schemas/reviews.py +28 -0
- memorytalk-0.5.0/memorytalk/schemas/search.py +55 -0
- memorytalk-0.5.0/memorytalk/schemas/session.py +133 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/schemas/status.py +8 -4
- memorytalk-0.5.0/memorytalk/schemas/sync.py +60 -0
- memorytalk-0.5.0/memorytalk/server.py +119 -0
- memorytalk-0.5.0/memorytalk/service/__init__.py +23 -0
- memorytalk-0.5.0/memorytalk/service/cards.py +202 -0
- memorytalk-0.5.0/memorytalk/service/events.py +34 -0
- memorytalk-0.5.0/memorytalk/service/read.py +105 -0
- memorytalk-0.5.0/memorytalk/service/recall.py +123 -0
- memorytalk-0.5.0/memorytalk/service/reviews.py +110 -0
- memorytalk-0.5.0/memorytalk/service/search.py +373 -0
- memorytalk-0.5.0/memorytalk/service/sessions.py +266 -0
- memorytalk-0.5.0/memorytalk/service/sync.py +505 -0
- memorytalk-0.5.0/memorytalk/util/__init__.py +4 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/util/console.py +11 -39
- memorytalk-0.5.0/memorytalk/util/dsl.py +285 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/util/env_template.py +11 -16
- memorytalk-0.5.0/memorytalk/util/formula.py +122 -0
- memorytalk-0.5.0/memorytalk/util/highlight.py +117 -0
- memorytalk-0.5.0/memorytalk/util/ids.py +65 -0
- memorytalk-0.5.0/memorytalk/util/indexes.py +48 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/util/settings_io.py +5 -6
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/PKG-INFO +3 -2
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/SOURCES.txt +20 -40
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/requires.txt +1 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/pyproject.toml +3 -8
- memorytalk-0.4.2/memorytalk/__init__.py +0 -0
- memorytalk-0.4.2/memorytalk/adapters/__init__.py +0 -4
- memorytalk-0.4.2/memorytalk/adapters/base.py +0 -33
- memorytalk-0.4.2/memorytalk/adapters/claude_code.py +0 -119
- memorytalk-0.4.2/memorytalk/api/__init__.py +0 -107
- memorytalk-0.4.2/memorytalk/api/links.py +0 -20
- memorytalk-0.4.2/memorytalk/api/log.py +0 -33
- memorytalk-0.4.2/memorytalk/api/rebuild.py +0 -21
- memorytalk-0.4.2/memorytalk/api/recall.py +0 -18
- memorytalk-0.4.2/memorytalk/api/review.py +0 -36
- memorytalk-0.4.2/memorytalk/api/search.py +0 -18
- memorytalk-0.4.2/memorytalk/api/sessions.py +0 -18
- memorytalk-0.4.2/memorytalk/api/tags.py +0 -71
- memorytalk-0.4.2/memorytalk/api/view.py +0 -33
- memorytalk-0.4.2/memorytalk/cli/__init__.py +0 -24
- memorytalk-0.4.2/memorytalk/cli/_format.py +0 -540
- memorytalk-0.4.2/memorytalk/cli/card.py +0 -41
- memorytalk-0.4.2/memorytalk/cli/filter.py +0 -402
- memorytalk-0.4.2/memorytalk/cli/link.py +0 -45
- memorytalk-0.4.2/memorytalk/cli/log.py +0 -32
- memorytalk-0.4.2/memorytalk/cli/rebuild.py +0 -31
- memorytalk-0.4.2/memorytalk/cli/recall.py +0 -122
- memorytalk-0.4.2/memorytalk/cli/review.py +0 -63
- memorytalk-0.4.2/memorytalk/cli/search.py +0 -40
- memorytalk-0.4.2/memorytalk/cli/server.py +0 -146
- memorytalk-0.4.2/memorytalk/cli/setup/__init__.py +0 -146
- memorytalk-0.4.2/memorytalk/cli/setup/steps/__init__.py +0 -0
- memorytalk-0.4.2/memorytalk/cli/setup/steps/claude_hook.py +0 -138
- memorytalk-0.4.2/memorytalk/cli/setup/steps/embedding.py +0 -200
- memorytalk-0.4.2/memorytalk/cli/setup/steps/path_takeover.py +0 -208
- memorytalk-0.4.2/memorytalk/cli/setup/steps/provider.py +0 -18
- memorytalk-0.4.2/memorytalk/cli/setup/steps/server.py +0 -71
- memorytalk-0.4.2/memorytalk/cli/setup/summary.py +0 -128
- memorytalk-0.4.2/memorytalk/cli/setup/venv.py +0 -117
- memorytalk-0.4.2/memorytalk/cli/setup/wizard.py +0 -117
- memorytalk-0.4.2/memorytalk/cli/sync.py +0 -57
- memorytalk-0.4.2/memorytalk/cli/tag.py +0 -88
- memorytalk-0.4.2/memorytalk/config.py +0 -183
- memorytalk-0.4.2/memorytalk/filters/__init__.py +0 -0
- memorytalk-0.4.2/memorytalk/filters/new-session/filter.py +0 -28
- memorytalk-0.4.2/memorytalk/filters/new-session/meta.json +0 -5
- memorytalk-0.4.2/memorytalk/provider/__init__.py +0 -0
- memorytalk-0.4.2/memorytalk/provider/lancedb.py +0 -182
- memorytalk-0.4.2/memorytalk/repository/__init__.py +0 -21
- memorytalk-0.4.2/memorytalk/repository/cards.py +0 -124
- memorytalk-0.4.2/memorytalk/repository/links.py +0 -101
- memorytalk-0.4.2/memorytalk/repository/recall.py +0 -255
- memorytalk-0.4.2/memorytalk/repository/schema.py +0 -114
- memorytalk-0.4.2/memorytalk/repository/search_log.py +0 -105
- memorytalk-0.4.2/memorytalk/repository/sessions.py +0 -230
- memorytalk-0.4.2/memorytalk/repository/tags.py +0 -134
- memorytalk-0.4.2/memorytalk/schemas/__init__.py +0 -63
- memorytalk-0.4.2/memorytalk/schemas/cards.py +0 -21
- memorytalk-0.4.2/memorytalk/schemas/links.py +0 -20
- memorytalk-0.4.2/memorytalk/schemas/log.py +0 -22
- memorytalk-0.4.2/memorytalk/schemas/rebuild.py +0 -12
- memorytalk-0.4.2/memorytalk/schemas/recall.py +0 -23
- memorytalk-0.4.2/memorytalk/schemas/review.py +0 -42
- memorytalk-0.4.2/memorytalk/schemas/search.py +0 -45
- memorytalk-0.4.2/memorytalk/schemas/sessions.py +0 -37
- memorytalk-0.4.2/memorytalk/schemas/shared.py +0 -48
- memorytalk-0.4.2/memorytalk/schemas/tags.py +0 -26
- memorytalk-0.4.2/memorytalk/schemas/view.py +0 -38
- memorytalk-0.4.2/memorytalk/service/__init__.py +0 -29
- memorytalk-0.4.2/memorytalk/service/cards.py +0 -267
- memorytalk-0.4.2/memorytalk/service/events.py +0 -34
- memorytalk-0.4.2/memorytalk/service/links.py +0 -128
- memorytalk-0.4.2/memorytalk/service/rebuild.py +0 -168
- memorytalk-0.4.2/memorytalk/service/recall.py +0 -125
- memorytalk-0.4.2/memorytalk/service/search.py +0 -186
- memorytalk-0.4.2/memorytalk/service/sessions.py +0 -289
- memorytalk-0.4.2/memorytalk/service/tags.py +0 -162
- memorytalk-0.4.2/memorytalk/util/__init__.py +0 -0
- memorytalk-0.4.2/memorytalk/util/dsl.py +0 -338
- memorytalk-0.4.2/memorytalk/util/ids.py +0 -66
- memorytalk-0.4.2/memorytalk/util/snippet.py +0 -82
- memorytalk-0.4.2/memorytalk/util/ttl.py +0 -60
- {memorytalk-0.4.2 → memorytalk-0.5.0}/LICENSE +0 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/README.md +0 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk/__main__.py +0 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/dependency_links.txt +0 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/entry_points.txt +0 -0
- {memorytalk-0.4.2 → memorytalk-0.5.0}/memorytalk.egg-info/top_level.txt +0 -0
- {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
|
-
Summary: Persistent cross-session memory for AI agents
|
|
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,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 /
|
|
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
|
|
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
|
-
|
|
17
|
-
except
|
|
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))
|