memorytalk 0.4.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 (71) hide show
  1. memorytalk/__init__.py +0 -0
  2. memorytalk/__main__.py +4 -0
  3. memorytalk/adapters/__init__.py +4 -0
  4. memorytalk/adapters/base.py +33 -0
  5. memorytalk/adapters/claude_code.py +119 -0
  6. memorytalk/api/__init__.py +100 -0
  7. memorytalk/api/cards.py +20 -0
  8. memorytalk/api/links.py +20 -0
  9. memorytalk/api/log.py +33 -0
  10. memorytalk/api/rebuild.py +21 -0
  11. memorytalk/api/search.py +18 -0
  12. memorytalk/api/sessions.py +18 -0
  13. memorytalk/api/status.py +25 -0
  14. memorytalk/api/tags.py +30 -0
  15. memorytalk/api/view.py +33 -0
  16. memorytalk/cli/__init__.py +21 -0
  17. memorytalk/cli/_format.py +435 -0
  18. memorytalk/cli/_http.py +73 -0
  19. memorytalk/cli/_render.py +71 -0
  20. memorytalk/cli/_setup_helpers.py +157 -0
  21. memorytalk/cli/card.py +41 -0
  22. memorytalk/cli/link.py +45 -0
  23. memorytalk/cli/log.py +32 -0
  24. memorytalk/cli/rebuild.py +31 -0
  25. memorytalk/cli/search.py +40 -0
  26. memorytalk/cli/server.py +146 -0
  27. memorytalk/cli/setup.py +429 -0
  28. memorytalk/cli/sync.py +57 -0
  29. memorytalk/cli/tag.py +51 -0
  30. memorytalk/cli/view.py +32 -0
  31. memorytalk/config.py +150 -0
  32. memorytalk/provider/__init__.py +0 -0
  33. memorytalk/provider/embedding.py +165 -0
  34. memorytalk/provider/lancedb.py +182 -0
  35. memorytalk/provider/storage.py +89 -0
  36. memorytalk/repository/__init__.py +18 -0
  37. memorytalk/repository/cards.py +124 -0
  38. memorytalk/repository/links.py +101 -0
  39. memorytalk/repository/schema.py +77 -0
  40. memorytalk/repository/search_log.py +105 -0
  41. memorytalk/repository/sessions.py +240 -0
  42. memorytalk/repository/store.py +50 -0
  43. memorytalk/schemas/__init__.py +51 -0
  44. memorytalk/schemas/cards.py +21 -0
  45. memorytalk/schemas/links.py +20 -0
  46. memorytalk/schemas/log.py +22 -0
  47. memorytalk/schemas/rebuild.py +12 -0
  48. memorytalk/schemas/search.py +44 -0
  49. memorytalk/schemas/sessions.py +37 -0
  50. memorytalk/schemas/shared.py +48 -0
  51. memorytalk/schemas/status.py +17 -0
  52. memorytalk/schemas/tags.py +14 -0
  53. memorytalk/schemas/view.py +36 -0
  54. memorytalk/service/__init__.py +25 -0
  55. memorytalk/service/cards.py +264 -0
  56. memorytalk/service/events.py +34 -0
  57. memorytalk/service/links.py +128 -0
  58. memorytalk/service/rebuild.py +130 -0
  59. memorytalk/service/search.py +182 -0
  60. memorytalk/service/sessions.py +309 -0
  61. memorytalk/util/__init__.py +0 -0
  62. memorytalk/util/dsl.py +327 -0
  63. memorytalk/util/ids.py +66 -0
  64. memorytalk/util/snippet.py +82 -0
  65. memorytalk/util/ttl.py +60 -0
  66. memorytalk-0.4.0.dist-info/METADATA +215 -0
  67. memorytalk-0.4.0.dist-info/RECORD +71 -0
  68. memorytalk-0.4.0.dist-info/WHEEL +5 -0
  69. memorytalk-0.4.0.dist-info/entry_points.txt +2 -0
  70. memorytalk-0.4.0.dist-info/licenses/LICENSE +201 -0
  71. memorytalk-0.4.0.dist-info/top_level.txt +1 -0
memorytalk/__init__.py ADDED
File without changes
memorytalk/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from memorytalk.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,4 @@
1
+ """Platform adapters for CLI `sync`."""
2
+ from memorytalk.adapters.base import BaseAdapter, ADAPTERS, get_adapter, register # noqa: F401
3
+ # Importing concrete adapters triggers @register side effects.
4
+ from memorytalk.adapters import claude_code # noqa: F401
@@ -0,0 +1,33 @@
1
+ """Base adapter interface for CLI `sync`."""
2
+ from __future__ import annotations
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Iterator
6
+
7
+
8
+ class BaseAdapter(ABC):
9
+ """Adapter for a source platform (claude-code / codex / ...).
10
+
11
+ `iter_sessions(root)` yields ingest payloads — dicts with the shape of
12
+ POST /v2/sessions body, including a computed sha256 for content hashing.
13
+ """
14
+
15
+ source_name: str
16
+
17
+ @abstractmethod
18
+ def iter_sessions(self, root: Path | None = None) -> Iterator[dict]: ...
19
+
20
+
21
+ ADAPTERS: dict[str, type[BaseAdapter]] = {}
22
+
23
+
24
+ def register(cls: type[BaseAdapter]) -> type[BaseAdapter]:
25
+ ADAPTERS[cls.source_name] = cls
26
+ return cls
27
+
28
+
29
+ def get_adapter(name: str) -> BaseAdapter:
30
+ cls = ADAPTERS.get(name)
31
+ if not cls:
32
+ raise ValueError(f"unknown adapter: {name}")
33
+ return cls()
@@ -0,0 +1,119 @@
1
+ """Claude Code adapter — reads ~/.claude/projects/*.jsonl conversation files
2
+ and produces POST /v2/sessions payloads.
3
+
4
+ Porting note: v1 produced Session model objects; v2 wants the HTTP request
5
+ body shape (raw platform session_id, dict rounds, sha256 over source bytes).
6
+ """
7
+ from __future__ import annotations
8
+ import hashlib
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Iterator
12
+ from urllib.parse import unquote
13
+
14
+ from memorytalk.adapters.base import BaseAdapter, register
15
+
16
+
17
+ @register
18
+ class ClaudeCodeAdapter(BaseAdapter):
19
+ source_name = "claude-code"
20
+
21
+ DEFAULT_ROOT = Path.home() / ".claude" / "projects"
22
+
23
+ def iter_sessions(self, root: Path | None = None) -> Iterator[dict]:
24
+ d = Path(root) if root else self.DEFAULT_ROOT
25
+ if not d.exists():
26
+ return
27
+ for path in sorted(d.rglob("*.jsonl")):
28
+ payload = self._convert_file(path)
29
+ if payload:
30
+ yield payload
31
+
32
+ def _convert_file(self, path: Path) -> dict | None:
33
+ raw_bytes = path.read_bytes()
34
+ sha256 = hashlib.sha256(raw_bytes).hexdigest()
35
+ session_id = path.stem # raw platform id, no sess_ prefix
36
+ project_dir = path.parent.name
37
+ project_name = unquote(project_dir)
38
+
39
+ rounds: list[dict] = []
40
+ created_at: str | None = None
41
+ for line in raw_bytes.decode("utf-8", errors="replace").splitlines():
42
+ line = line.strip()
43
+ if not line:
44
+ continue
45
+ try:
46
+ msg = json.loads(line)
47
+ except json.JSONDecodeError:
48
+ continue
49
+ r = self._parse_message(msg)
50
+ if r is None:
51
+ continue
52
+ rounds.append(r)
53
+ if created_at is None and r.get("timestamp"):
54
+ created_at = r["timestamp"]
55
+
56
+ if not rounds:
57
+ return None
58
+
59
+ return {
60
+ "session_id": session_id,
61
+ "source": self.source_name,
62
+ "created_at": created_at or "",
63
+ "metadata": {"project": project_name, "path": str(path)},
64
+ "sha256": sha256,
65
+ "rounds": rounds,
66
+ }
67
+
68
+ def _parse_message(self, msg: dict) -> dict | None:
69
+ msg_type = msg.get("type")
70
+ if msg_type not in ("user", "assistant"):
71
+ return None
72
+ role = "human" if msg_type == "user" else "assistant"
73
+ speaker = "user" if msg_type == "user" else "assistant"
74
+ raw_content = msg.get("message", {}).get("content", [])
75
+ blocks = self._parse_content(raw_content)
76
+ if not blocks:
77
+ if isinstance(raw_content, str):
78
+ blocks = [{"type": "text", "text": raw_content}]
79
+ else:
80
+ return None
81
+ return {
82
+ "round_id": msg.get("uuid", ""),
83
+ "parent_id": msg.get("parentUuid"),
84
+ "timestamp": msg.get("timestamp"),
85
+ "speaker": speaker,
86
+ "role": role,
87
+ "content": blocks,
88
+ "is_sidechain": bool(msg.get("isSidechain")),
89
+ "cwd": msg.get("cwd"),
90
+ }
91
+
92
+ def _parse_content(self, content_list) -> list[dict]:
93
+ if not isinstance(content_list, list):
94
+ return []
95
+ out: list[dict] = []
96
+ for block in content_list:
97
+ if not isinstance(block, dict):
98
+ continue
99
+ t = block.get("type")
100
+ if t == "text":
101
+ text = block.get("text") or ""
102
+ if text:
103
+ out.append({"type": "text", "text": text})
104
+ elif t == "thinking":
105
+ thinking = block.get("thinking") or ""
106
+ if thinking:
107
+ out.append({"type": "thinking", "thinking": thinking})
108
+ elif t == "tool_use":
109
+ name = block.get("name", "tool")
110
+ inp = block.get("input", "")
111
+ if isinstance(inp, dict):
112
+ inp = json.dumps(inp, ensure_ascii=False)
113
+ out.append({"type": "text", "text": f"[{name}] {inp}"})
114
+ elif t == "tool_result":
115
+ c = block.get("content", "")
116
+ if isinstance(c, list):
117
+ c = json.dumps(c, ensure_ascii=False)
118
+ out.append({"type": "text", "text": str(c)})
119
+ return out
@@ -0,0 +1,100 @@
1
+ """FastAPI app factory for v2 — async setup via lifespan."""
2
+ from __future__ import annotations
3
+ import os
4
+ import sys
5
+ from contextlib import asynccontextmanager
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from memorytalk.config import Config, ConfigValidationError
11
+ from memorytalk.provider.embedding import (
12
+ EmbedderValidationError,
13
+ get_embedder,
14
+ validate_embedder,
15
+ )
16
+ from memorytalk.service import (
17
+ CardService, EventWriter, LinkService, RebuildService,
18
+ SearchService, SessionService,
19
+ )
20
+ from memorytalk.provider.lancedb import LanceStore
21
+ from memorytalk.provider.storage import LocalStorage
22
+ from memorytalk.repository import SQLiteStore
23
+
24
+
25
+ def create_app(config: Config | None = None) -> FastAPI:
26
+ config = config or Config()
27
+ try:
28
+ config.validate()
29
+ except ConfigValidationError as e:
30
+ print(f"[memory-talk] config validation failed: {e}", file=sys.stderr)
31
+ raise SystemExit(2) from e
32
+
33
+ config.ensure_dirs()
34
+
35
+ @asynccontextmanager
36
+ async def lifespan(app: FastAPI):
37
+ # Async startup — open DB, LanceDB, probe embedding, wire services.
38
+ storage = LocalStorage(config.data_root)
39
+ db = await SQLiteStore.create(config.db_path, storage)
40
+ vectors = await LanceStore.create(config.vectors_dir, dim=config.settings.embedding.dim)
41
+ embedder = get_embedder(config)
42
+ events = EventWriter(db)
43
+
44
+ try:
45
+ await validate_embedder(config)
46
+ except EmbedderValidationError as e:
47
+ print(f"[memory-talk] embedding startup check failed: {e}", file=sys.stderr)
48
+ raise SystemExit(2) from e
49
+
50
+ app.state.config = config
51
+ app.state.storage = storage
52
+ app.state.db = db
53
+ app.state.vectors = vectors
54
+ app.state.embedder = embedder
55
+ app.state.sessions = SessionService(
56
+ config=config, db=db, vectors=vectors, events=events,
57
+ )
58
+ app.state.cards = CardService(
59
+ config=config, db=db, vectors=vectors, embedder=embedder, events=events,
60
+ )
61
+ app.state.links = LinkService(config=config, db=db, events=events)
62
+ app.state.search = SearchService(
63
+ config=config, db=db, vectors=vectors, embedder=embedder,
64
+ )
65
+ app.state.rebuild = RebuildService(
66
+ config=config, db=db, vectors=vectors, embedder=embedder,
67
+ )
68
+
69
+ yield
70
+
71
+ await db.close()
72
+
73
+ app = FastAPI(title="memory.talk v2", lifespan=lifespan)
74
+ app.state.config = config
75
+ app.state.status = "running"
76
+
77
+ @app.middleware("http")
78
+ async def rebuild_gate(request: Request, call_next):
79
+ status = getattr(request.app.state, "status", "running")
80
+ if status == "running":
81
+ return await call_next(request)
82
+ if request.method == "GET" and request.url.path == "/v2/status":
83
+ return await call_next(request)
84
+ return JSONResponse({"error": "rebuilding"}, status_code=503)
85
+
86
+ from memorytalk.api.status import router as status_router
87
+ app.include_router(status_router, prefix="/v2")
88
+
89
+ for name in ("sessions", "cards", "links", "tags", "search", "view", "log", "rebuild"):
90
+ try:
91
+ mod = __import__(f"memorytalk.api.{name}", fromlist=["router"])
92
+ app.include_router(mod.router, prefix="/v2")
93
+ except ImportError:
94
+ pass
95
+
96
+ return app
97
+
98
+
99
+ _data_root = os.environ.get("MEMORY_TALK_DATA_ROOT")
100
+ app = create_app(Config(_data_root) if _data_root else Config())
@@ -0,0 +1,20 @@
1
+ """POST /v2/cards."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import CreateCardRequest, CreateCardResponse
7
+ from memorytalk.service import CardConflictError, CardServiceError
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/cards", response_model=CreateCardResponse)
14
+ async def post_cards(payload: CreateCardRequest, request: Request):
15
+ try:
16
+ return await request.app.state.cards.create(payload)
17
+ except CardConflictError as e:
18
+ raise HTTPException(status_code=409, detail=str(e))
19
+ except CardServiceError as e:
20
+ raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,20 @@
1
+ """POST /v2/links."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import CreateLinkRequest, CreateLinkResponse
7
+ from memorytalk.service import LinkNotFoundError, LinkServiceError
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/links", response_model=CreateLinkResponse)
14
+ async def post_links(payload: CreateLinkRequest, request: Request):
15
+ try:
16
+ return await request.app.state.links.create(payload)
17
+ except LinkNotFoundError as e:
18
+ raise HTTPException(status_code=404, detail=str(e))
19
+ except LinkServiceError as e:
20
+ raise HTTPException(status_code=400, detail=str(e))
memorytalk/api/log.py ADDED
@@ -0,0 +1,33 @@
1
+ """POST /v2/log — prefix-dispatched event stream."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.util.ids import IdKind, InvalidIdError, parse_id
7
+ from memorytalk.schemas import LogRequest, LogResponse
8
+ from memorytalk.service import (
9
+ CardNotFound, CardServiceError, SessionNotFound, SessionServiceError,
10
+ )
11
+
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.post("/log", response_model=LogResponse)
17
+ async def post_log(payload: LogRequest, request: Request):
18
+ try:
19
+ kind, _ = parse_id(payload.id)
20
+ except InvalidIdError:
21
+ raise HTTPException(status_code=400, detail="invalid id prefix")
22
+
23
+ try:
24
+ if kind == IdKind.CARD:
25
+ return await request.app.state.cards.log(payload.id)
26
+ if kind == IdKind.SESSION:
27
+ return await request.app.state.sessions.log(payload.id)
28
+ except (CardNotFound, SessionNotFound) as e:
29
+ raise HTTPException(status_code=404, detail=str(e))
30
+ except (CardServiceError, SessionServiceError) as e:
31
+ raise HTTPException(status_code=400, detail=str(e))
32
+
33
+ raise HTTPException(status_code=400, detail="invalid id prefix")
@@ -0,0 +1,21 @@
1
+ """POST /v2/rebuild."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import RebuildResponse
7
+
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.post("/rebuild", response_model=RebuildResponse)
13
+ async def post_rebuild(request: Request):
14
+ app = request.app
15
+ if getattr(app.state, "status", "running") != "running":
16
+ raise HTTPException(status_code=409, detail="rebuild already in progress")
17
+ app.state.status = "rebuilding"
18
+ try:
19
+ return await app.state.rebuild.rebuild()
20
+ finally:
21
+ app.state.status = "running"
@@ -0,0 +1,18 @@
1
+ """POST /v2/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.service import SearchError
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/search", response_model=SearchResponse)
14
+ async def post_search(payload: SearchRequest, request: Request):
15
+ try:
16
+ return await request.app.state.search.search(payload)
17
+ except SearchError as e:
18
+ raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,18 @@
1
+ """POST /v2/sessions — ingest."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import IngestSessionRequest, IngestSessionResponse
7
+ from memorytalk.service import SessionServiceError
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/sessions", response_model=IngestSessionResponse)
14
+ async def post_sessions(payload: IngestSessionRequest, request: Request):
15
+ try:
16
+ return await request.app.state.sessions.ingest(payload)
17
+ except SessionServiceError as e:
18
+ raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,25 @@
1
+ """GET /v2/status — stats and running info."""
2
+ from __future__ import annotations
3
+ from fastapi import APIRouter, Request
4
+
5
+ from memorytalk.schemas import StatusResponse
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/status", response_model=StatusResponse)
11
+ async def get_status(request: Request) -> StatusResponse:
12
+ config = request.app.state.config
13
+ db = request.app.state.db
14
+ return StatusResponse(
15
+ data_root=str(config.data_root),
16
+ settings_path=str(config.settings_path),
17
+ status=getattr(request.app.state, "status", "running"),
18
+ sessions_total=await db.sessions.count(),
19
+ cards_total=await db.cards.count(),
20
+ links_total=await db.links.count(),
21
+ searches_total=await db.search_log.count(),
22
+ vector_provider=config.settings.vector.provider,
23
+ relation_provider=config.settings.relation.provider,
24
+ embedding_provider=config.settings.embedding.provider,
25
+ )
memorytalk/api/tags.py ADDED
@@ -0,0 +1,30 @@
1
+ """POST /v2/tags/{add,remove}."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import TagsRequest, TagsResponse
7
+ from memorytalk.service import SessionNotFound, SessionServiceError
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/tags/add", response_model=TagsResponse)
14
+ async def post_tags_add(payload: TagsRequest, request: Request):
15
+ try:
16
+ return await request.app.state.sessions.add_tags(payload)
17
+ except SessionNotFound as e:
18
+ raise HTTPException(status_code=404, detail=str(e))
19
+ except SessionServiceError as e:
20
+ raise HTTPException(status_code=400, detail=str(e))
21
+
22
+
23
+ @router.post("/tags/remove", response_model=TagsResponse)
24
+ async def post_tags_remove(payload: TagsRequest, request: Request):
25
+ try:
26
+ return await request.app.state.sessions.remove_tags(payload)
27
+ except SessionNotFound as e:
28
+ raise HTTPException(status_code=404, detail=str(e))
29
+ except SessionServiceError as e:
30
+ raise HTTPException(status_code=400, detail=str(e))
memorytalk/api/view.py ADDED
@@ -0,0 +1,33 @@
1
+ """POST /v2/view — prefix-dispatched read."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.util.ids import IdKind, InvalidIdError, parse_id
7
+ from memorytalk.schemas import ViewRequest, ViewResponse
8
+ from memorytalk.service import (
9
+ CardNotFound, CardServiceError, SessionNotFound, SessionServiceError,
10
+ )
11
+
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.post("/view", response_model=ViewResponse)
17
+ async def post_view(payload: ViewRequest, request: Request):
18
+ try:
19
+ kind, _ = parse_id(payload.id)
20
+ except InvalidIdError:
21
+ raise HTTPException(status_code=400, detail="invalid id prefix")
22
+
23
+ try:
24
+ if kind == IdKind.CARD:
25
+ return await request.app.state.cards.view(payload.id)
26
+ if kind == IdKind.SESSION:
27
+ return await request.app.state.sessions.view(payload.id)
28
+ except (CardNotFound, SessionNotFound) as e:
29
+ raise HTTPException(status_code=404, detail=str(e))
30
+ except (CardServiceError, SessionServiceError) as e:
31
+ raise HTTPException(status_code=400, detail=str(e))
32
+
33
+ raise HTTPException(status_code=400, detail="invalid id prefix")
@@ -0,0 +1,21 @@
1
+ """memory-talk v2 CLI root."""
2
+ from __future__ import annotations
3
+ import click
4
+
5
+ from memorytalk.cli.server import server
6
+
7
+
8
+ @click.group()
9
+ def main() -> None:
10
+ """memory-talk v2."""
11
+
12
+
13
+ main.add_command(server)
14
+
15
+ # Other command groups are attached as they're implemented.
16
+ for _name in ("card", "tag", "link", "sync", "search", "view", "log", "rebuild", "setup"):
17
+ try:
18
+ _mod = __import__(f"memorytalk.cli.{_name}", fromlist=[_name])
19
+ main.add_command(getattr(_mod, _name))
20
+ except ImportError:
21
+ pass