memorytalk 0.4.1__tar.gz → 0.4.2__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.1 → memorytalk-0.4.2}/PKG-INFO +2 -1
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/__init__.py +5 -1
- memorytalk-0.4.2/memorytalk/api/tags.py +71 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/__init__.py +5 -2
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/_format.py +31 -5
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/_http.py +3 -2
- memorytalk-0.4.2/memorytalk/cli/filter.py +402 -0
- memorytalk-0.4.2/memorytalk/cli/recall.py +122 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/__init__.py +50 -19
- memorytalk-0.4.2/memorytalk/cli/setup/steps/claude_hook.py +138 -0
- memorytalk-0.4.2/memorytalk/cli/setup/steps/embedding.py +200 -0
- memorytalk-0.4.2/memorytalk/cli/setup/steps/path_takeover.py +208 -0
- memorytalk-0.4.2/memorytalk/cli/setup/steps/provider.py +18 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/steps/server.py +23 -12
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/summary.py +44 -24
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/venv.py +1 -1
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/wizard.py +40 -28
- memorytalk-0.4.2/memorytalk/cli/tag.py +88 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/config.py +33 -7
- memorytalk-0.4.2/memorytalk/filters/new-session/filter.py +28 -0
- memorytalk-0.4.2/memorytalk/filters/new-session/meta.json +5 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/provider/embedding.py +9 -16
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/__init__.py +2 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/recall.py +13 -6
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/schema.py +16 -1
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/sessions.py +4 -14
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/store.py +3 -1
- memorytalk-0.4.2/memorytalk/repository/tags.py +134 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/__init__.py +2 -2
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/review.py +1 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/search.py +2 -1
- memorytalk-0.4.2/memorytalk/schemas/tags.py +26 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/view.py +3 -1
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/__init__.py +2 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/cards.py +4 -1
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/rebuild.py +41 -3
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/recall.py +22 -11
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/search.py +7 -3
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/sessions.py +35 -55
- memorytalk-0.4.2/memorytalk/service/tags.py +162 -0
- memorytalk-0.4.2/memorytalk/util/__init__.py +0 -0
- memorytalk-0.4.2/memorytalk/util/console.py +156 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/util/dsl.py +17 -6
- memorytalk-0.4.2/memorytalk/util/env_template.py +45 -0
- memorytalk-0.4.2/memorytalk/util/settings_io.py +52 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/PKG-INFO +2 -1
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/SOURCES.txt +11 -3
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/requires.txt +1 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/pyproject.toml +8 -1
- memorytalk-0.4.1/memorytalk/api/tags.py +0 -30
- memorytalk-0.4.1/memorytalk/cli/recall.py +0 -38
- memorytalk-0.4.1/memorytalk/cli/setup/_io.py +0 -14
- memorytalk-0.4.1/memorytalk/cli/setup/helpers.py +0 -121
- memorytalk-0.4.1/memorytalk/cli/setup/steps/alias.py +0 -30
- memorytalk-0.4.1/memorytalk/cli/setup/steps/embedding.py +0 -91
- memorytalk-0.4.1/memorytalk/cli/setup/steps/provider.py +0 -17
- memorytalk-0.4.1/memorytalk/cli/tag.py +0 -51
- memorytalk-0.4.1/memorytalk/schemas/tags.py +0 -14
- {memorytalk-0.4.1 → memorytalk-0.4.2}/LICENSE +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/README.md +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/__init__.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/__main__.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/adapters/__init__.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/adapters/base.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/adapters/claude_code.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/cards.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/links.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/log.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/rebuild.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/recall.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/review.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/search.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/sessions.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/status.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/view.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/_render.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/card.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/link.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/log.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/rebuild.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/review.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/search.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/server.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/steps/__init__.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/sync.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/view.py +0 -0
- {memorytalk-0.4.1/memorytalk/provider → memorytalk-0.4.2/memorytalk/filters}/__init__.py +0 -0
- {memorytalk-0.4.1/memorytalk/util → memorytalk-0.4.2/memorytalk/provider}/__init__.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/provider/lancedb.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/provider/storage.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/cards.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/links.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/search_log.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/cards.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/links.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/log.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/rebuild.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/recall.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/sessions.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/shared.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/status.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/events.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/links.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/util/ids.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/util/snippet.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/util/ttl.py +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/dependency_links.txt +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/entry_points.txt +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/top_level.txt +0 -0
- {memorytalk-0.4.1 → memorytalk-0.4.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memorytalk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Persistent cross-session memory for AI agents via Talk-Card architecture (v2)
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -19,6 +19,7 @@ Requires-Dist: pyarrow>=14.0.0
|
|
|
19
19
|
Requires-Dist: aiosqlite>=0.19.0
|
|
20
20
|
Requires-Dist: aiofiles>=23.0.0
|
|
21
21
|
Requires-Dist: rich>=13.0.0
|
|
22
|
+
Requires-Dist: questionary>=2.0.0
|
|
22
23
|
Provides-Extra: dev
|
|
23
24
|
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
24
25
|
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
@@ -15,7 +15,7 @@ from memorytalk.provider.embedding import (
|
|
|
15
15
|
)
|
|
16
16
|
from memorytalk.service import (
|
|
17
17
|
CardService, EventWriter, LinkService, RebuildService,
|
|
18
|
-
RecallService, SearchService, SessionService,
|
|
18
|
+
RecallService, SearchService, SessionService, TagService,
|
|
19
19
|
)
|
|
20
20
|
from memorytalk.provider.lancedb import LanceStore
|
|
21
21
|
from memorytalk.provider.storage import LocalStorage
|
|
@@ -52,8 +52,12 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
52
52
|
app.state.db = db
|
|
53
53
|
app.state.vectors = vectors
|
|
54
54
|
app.state.embedder = embedder
|
|
55
|
+
# TagService must be wired before SessionService (the latter holds
|
|
56
|
+
# a reference so ingest can stamp `sync_session` tags).
|
|
57
|
+
app.state.tags = TagService(db=db, storage=storage, events=events)
|
|
55
58
|
app.state.sessions = SessionService(
|
|
56
59
|
config=config, db=db, vectors=vectors, events=events,
|
|
60
|
+
tags=app.state.tags,
|
|
57
61
|
)
|
|
58
62
|
app.state.cards = CardService(
|
|
59
63
|
config=config, db=db, vectors=vectors, embedder=embedder, events=events,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Tag endpoints — resource-rooted (subject in URL path).
|
|
2
|
+
|
|
3
|
+
Sessions:
|
|
4
|
+
POST /v2/sessions/{session_id}/tags body: {"tags": ["k:v", ...]}
|
|
5
|
+
DELETE /v2/sessions/{session_id}/tags?key=k1&key=k2
|
|
6
|
+
|
|
7
|
+
Cards (commit 2 will enable):
|
|
8
|
+
POST /v2/cards/{card_id}/tags
|
|
9
|
+
DELETE /v2/cards/{card_id}/tags?key=...
|
|
10
|
+
|
|
11
|
+
Subject-id prefix validation happens inside TagService — the path param
|
|
12
|
+
just plumbs whatever string the URL had through. A wrong-prefix id
|
|
13
|
+
(e.g. ``sess_xxx`` posted to the cards route) returns 400 from the
|
|
14
|
+
service layer rather than a 404 from FastAPI routing.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
19
|
+
|
|
20
|
+
from memorytalk.schemas import TagsAddRequest, TagsResponse
|
|
21
|
+
from memorytalk.service import SessionNotFound, TagServiceError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _wrap_call(coro):
|
|
28
|
+
try:
|
|
29
|
+
return await coro
|
|
30
|
+
except SessionNotFound as e:
|
|
31
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
32
|
+
except TagServiceError as e:
|
|
33
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/sessions/{session_id}/tags", response_model=TagsResponse)
|
|
37
|
+
async def post_session_tags(
|
|
38
|
+
session_id: str, payload: TagsAddRequest, request: Request,
|
|
39
|
+
) -> TagsResponse:
|
|
40
|
+
return await _wrap_call(
|
|
41
|
+
request.app.state.tags.add_tags(session_id, payload.tags)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.delete("/sessions/{session_id}/tags", response_model=TagsResponse)
|
|
46
|
+
async def delete_session_tags(
|
|
47
|
+
session_id: str, request: Request,
|
|
48
|
+
key: list[str] = Query(..., min_length=1),
|
|
49
|
+
) -> TagsResponse:
|
|
50
|
+
return await _wrap_call(
|
|
51
|
+
request.app.state.tags.remove_tags(session_id, key)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.post("/cards/{card_id}/tags", response_model=TagsResponse)
|
|
56
|
+
async def post_card_tags(
|
|
57
|
+
card_id: str, payload: TagsAddRequest, request: Request,
|
|
58
|
+
) -> TagsResponse:
|
|
59
|
+
return await _wrap_call(
|
|
60
|
+
request.app.state.tags.add_tags(card_id, payload.tags)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.delete("/cards/{card_id}/tags", response_model=TagsResponse)
|
|
65
|
+
async def delete_card_tags(
|
|
66
|
+
card_id: str, request: Request,
|
|
67
|
+
key: list[str] = Query(..., min_length=1),
|
|
68
|
+
) -> TagsResponse:
|
|
69
|
+
return await _wrap_call(
|
|
70
|
+
request.app.state.tags.remove_tags(card_id, key)
|
|
71
|
+
)
|
|
@@ -13,9 +13,12 @@ def main() -> None:
|
|
|
13
13
|
main.add_command(server)
|
|
14
14
|
|
|
15
15
|
# Other command groups are attached as they're implemented.
|
|
16
|
-
for _name in ("card", "tag", "link", "sync", "search", "recall", "review", "view", "log", "rebuild", "setup"):
|
|
16
|
+
for _name in ("card", "tag", "link", "sync", "search", "recall", "review", "view", "log", "rebuild", "setup", "filter"):
|
|
17
17
|
try:
|
|
18
18
|
_mod = __import__(f"memorytalk.cli.{_name}", fromlist=[_name])
|
|
19
|
-
|
|
19
|
+
# `filter` shadows a builtin → the command object is named `filter_`
|
|
20
|
+
# in the module; everything else uses the same name as the module.
|
|
21
|
+
attr = "filter_" if _name == "filter" else _name
|
|
22
|
+
main.add_command(getattr(_mod, attr))
|
|
20
23
|
except ImportError:
|
|
21
24
|
pass
|
|
@@ -141,6 +141,10 @@ def fmt_view_card(resp: dict) -> str:
|
|
|
141
141
|
if card.get("summary"):
|
|
142
142
|
out.append(f"**Summary:** {card['summary']}")
|
|
143
143
|
out.append("")
|
|
144
|
+
tags = card.get("tags") or []
|
|
145
|
+
if tags:
|
|
146
|
+
out.append("**Tags:** " + ", ".join(f"`{_tag_label(t)}`" for t in tags))
|
|
147
|
+
out.append("")
|
|
144
148
|
|
|
145
149
|
links = resp.get("links") or []
|
|
146
150
|
out.append(f"## links ({len(links)})")
|
|
@@ -180,7 +184,7 @@ def fmt_view_session(resp: dict) -> str:
|
|
|
180
184
|
out.append("")
|
|
181
185
|
tags = sess.get("tags") or []
|
|
182
186
|
if tags:
|
|
183
|
-
out.append("**Tags:** " + ", ".join(f"`{t}`" for t in tags))
|
|
187
|
+
out.append("**Tags:** " + ", ".join(f"`{_tag_label(t)}`" for t in tags))
|
|
184
188
|
out.append("")
|
|
185
189
|
metadata = sess.get("metadata") or {}
|
|
186
190
|
if metadata:
|
|
@@ -247,8 +251,18 @@ def _detail_summary(kind: str, detail: dict) -> str:
|
|
|
247
251
|
if kind == "rounds_overwrite_skipped":
|
|
248
252
|
idxs = detail.get("indexes", [])
|
|
249
253
|
return f"indexes={','.join(str(i) for i in idxs)}"
|
|
250
|
-
if kind
|
|
251
|
-
|
|
254
|
+
if kind in ("tag_added", "tag_removed"):
|
|
255
|
+
key = detail.get("key", "")
|
|
256
|
+
value = detail.get("value", "") or ""
|
|
257
|
+
label = f"{key}:{value}" if value else key
|
|
258
|
+
return f"`{label}`"
|
|
259
|
+
if kind == "tag_updated":
|
|
260
|
+
key = detail.get("key", "")
|
|
261
|
+
value = detail.get("value", "") or ""
|
|
262
|
+
prior = detail.get("prior_value", "") or ""
|
|
263
|
+
new_label = f"{key}:{value}" if value else key
|
|
264
|
+
old_label = f"{key}:{prior}" if prior else key
|
|
265
|
+
return f"`{old_label}` → `{new_label}`"
|
|
252
266
|
if kind == "card_extracted":
|
|
253
267
|
return f"`{detail.get('card_id', '')}` · indexes={detail.get('indexes', '')}"
|
|
254
268
|
if kind == "linked":
|
|
@@ -375,7 +389,12 @@ def fmt_review_detail(resp: dict) -> str:
|
|
|
375
389
|
out.append(f"> {q}")
|
|
376
390
|
out.append("")
|
|
377
391
|
for hit in r.get("hits") or []:
|
|
378
|
-
|
|
392
|
+
cid = hit.get("card_id", "")
|
|
393
|
+
summary = (hit.get("summary") or "").replace("\n", " ").strip()
|
|
394
|
+
line = f"{hit.get('rank', '?')}. `{cid}`"
|
|
395
|
+
if summary:
|
|
396
|
+
line += f" — {summary}"
|
|
397
|
+
out.append(line)
|
|
379
398
|
out.append("")
|
|
380
399
|
return _join(*out)
|
|
381
400
|
|
|
@@ -410,11 +429,18 @@ def fmt_link_create(resp: dict) -> str:
|
|
|
410
429
|
return f"ok: linked `{resp.get('link_id', '')}`\n"
|
|
411
430
|
|
|
412
431
|
|
|
432
|
+
def _tag_label(pair: dict) -> str:
|
|
433
|
+
"""Render `{"key": "k", "value": "v"}` as ``k:v`` (or just ``k`` if value is empty)."""
|
|
434
|
+
key = pair.get("key", "")
|
|
435
|
+
value = pair.get("value", "") or ""
|
|
436
|
+
return f"{key}:{value}" if value else key
|
|
437
|
+
|
|
438
|
+
|
|
413
439
|
def fmt_tag(resp: dict) -> str:
|
|
414
440
|
tags = resp.get("tags") or []
|
|
415
441
|
if not tags:
|
|
416
442
|
return "ok: tags = *(empty)*\n"
|
|
417
|
-
return "ok: tags = " + ", ".join(f"`{
|
|
443
|
+
return "ok: tags = " + ", ".join(f"`{_tag_label(p)}`" for p in tags) + "\n"
|
|
418
444
|
|
|
419
445
|
|
|
420
446
|
# ---------- sync / rebuild ----------
|
|
@@ -57,13 +57,14 @@ _make_client: Optional[Callable[[Config], httpx.Client]] = None
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def api(method: str, path: str, config: Config,
|
|
60
|
-
json_body: dict | None = None, timeout: float = 30.0
|
|
60
|
+
json_body: dict | None = None, timeout: float = 30.0,
|
|
61
|
+
params: dict | list[tuple[str, str]] | None = None) -> dict:
|
|
61
62
|
factory = _make_client or _default_client
|
|
62
63
|
client = factory(config)
|
|
63
64
|
# No `with` — ASGI test transport has no context-manager support, and
|
|
64
65
|
# the CLI is a short-lived process where leaked TCP sockets get reaped
|
|
65
66
|
# at exit. Tests share a long-lived ASGI client across calls.
|
|
66
|
-
resp = client.request(method, path, json=json_body, timeout=timeout)
|
|
67
|
+
resp = client.request(method, path, json=json_body, timeout=timeout, params=params)
|
|
67
68
|
if resp.status_code >= 400:
|
|
68
69
|
try:
|
|
69
70
|
payload = resp.json()
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""CLI: filter list / run / mark / unmark.
|
|
2
|
+
|
|
3
|
+
Filter is a viewfinder: it does NOT do work. filter.py is a pure
|
|
4
|
+
Python module that exports ``select(client) -> list[str]``; meta.json
|
|
5
|
+
declares a mark_tag schema (lists of tags to add/remove). `mark`
|
|
6
|
+
applies the schema to specific subjects; `unmark` reverses.
|
|
7
|
+
|
|
8
|
+
filter.py runs **in-process** via importlib (no subprocess) — that
|
|
9
|
+
way it shares the CLI's HTTP client (and any test-time monkeypatch
|
|
10
|
+
of ``_make_client``), keeping the test path identical to production.
|
|
11
|
+
|
|
12
|
+
Built-in filters live in ``memorytalk/filters/`` (shipped with the
|
|
13
|
+
package); user filters live in ``<data_root>/filters/``. User filters
|
|
14
|
+
override built-ins on name conflict.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
import importlib.util
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
import memorytalk
|
|
27
|
+
from memorytalk.cli._format import fmt_error
|
|
28
|
+
from memorytalk.cli._http import ApiError, api, extract_error_message
|
|
29
|
+
from memorytalk.cli._render import emit_json, emit_json_err, emit_md, emit_md_err
|
|
30
|
+
from memorytalk.config import Config
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
34
|
+
BUILTIN_FILTERS_DIR = Path(memorytalk.__file__).parent / "filters"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class FilterInfo:
|
|
39
|
+
name: str
|
|
40
|
+
dir: Path
|
|
41
|
+
source: str # "builtin" or "user"
|
|
42
|
+
meta: dict
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------- discovery / loading ----------
|
|
46
|
+
|
|
47
|
+
def _user_filters_dir(cfg: Config) -> Path:
|
|
48
|
+
return cfg.data_root / "filters"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_valid_filter_dir(d: Path) -> bool:
|
|
52
|
+
return (d / "filter.py").is_file() and (d / "meta.json").is_file()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_meta(meta: dict) -> dict:
|
|
56
|
+
if "mark_tag" not in meta or not isinstance(meta["mark_tag"], dict):
|
|
57
|
+
raise ValueError("meta.json missing or invalid 'mark_tag' field")
|
|
58
|
+
mt = meta["mark_tag"]
|
|
59
|
+
add = mt.get("add") or []
|
|
60
|
+
remove = mt.get("remove") or []
|
|
61
|
+
if not isinstance(add, list) or not isinstance(remove, list):
|
|
62
|
+
raise ValueError("mark_tag.add and mark_tag.remove must be lists")
|
|
63
|
+
if not add and not remove:
|
|
64
|
+
raise ValueError("mark_tag.add and mark_tag.remove cannot both be empty")
|
|
65
|
+
for t in add + remove:
|
|
66
|
+
if not isinstance(t, str) or not t.strip():
|
|
67
|
+
raise ValueError(f"invalid tag entry: {t!r}")
|
|
68
|
+
# Normalize
|
|
69
|
+
meta["mark_tag"] = {"add": add, "remove": remove}
|
|
70
|
+
return meta
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_filter_dir(d: Path, source: str) -> FilterInfo | None:
|
|
74
|
+
if not NAME_RE.match(d.name):
|
|
75
|
+
return None
|
|
76
|
+
if not _is_valid_filter_dir(d):
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
with open(d / "meta.json") as f:
|
|
80
|
+
meta = json.load(f)
|
|
81
|
+
meta = _validate_meta(meta)
|
|
82
|
+
except (json.JSONDecodeError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
return FilterInfo(name=d.name, dir=d, source=source, meta=meta)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _discover_filters(cfg: Config) -> list[FilterInfo]:
|
|
88
|
+
"""Enumerate builtin + user filters; user overrides builtin on name."""
|
|
89
|
+
by_name: dict[str, FilterInfo] = {}
|
|
90
|
+
for source, base in (("builtin", BUILTIN_FILTERS_DIR), ("user", _user_filters_dir(cfg))):
|
|
91
|
+
if not base.is_dir():
|
|
92
|
+
continue
|
|
93
|
+
for sub in sorted(base.iterdir()):
|
|
94
|
+
if not sub.is_dir():
|
|
95
|
+
continue
|
|
96
|
+
info = _load_filter_dir(sub, source)
|
|
97
|
+
if info is not None:
|
|
98
|
+
by_name[info.name] = info
|
|
99
|
+
return sorted(by_name.values(), key=lambda i: i.name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_filter(cfg: Config, name: str) -> FilterInfo:
|
|
103
|
+
if not NAME_RE.match(name):
|
|
104
|
+
raise click.ClickException(f"invalid filter name: {name!r}")
|
|
105
|
+
user = _user_filters_dir(cfg) / name
|
|
106
|
+
builtin = BUILTIN_FILTERS_DIR / name
|
|
107
|
+
for d, source in ((user, "user"), (builtin, "builtin")):
|
|
108
|
+
if d.is_dir():
|
|
109
|
+
if not _is_valid_filter_dir(d):
|
|
110
|
+
raise click.ClickException(
|
|
111
|
+
f"filter {name!r}: missing filter.py or meta.json"
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
with open(d / "meta.json") as f:
|
|
115
|
+
meta = json.load(f)
|
|
116
|
+
meta = _validate_meta(meta)
|
|
117
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
118
|
+
raise click.ClickException(f"filter {name!r}: {e}")
|
|
119
|
+
return FilterInfo(name=name, dir=d, source=source, meta=meta)
|
|
120
|
+
raise click.ClickException(f"filter not found: {name}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------- run filter.py ----------
|
|
124
|
+
|
|
125
|
+
def _import_filter_module(info: FilterInfo):
|
|
126
|
+
"""Load filter.py as a fresh module via importlib (no subprocess)."""
|
|
127
|
+
mod_name = f"_memorytalk_filter_{info.name.replace('-', '_')}"
|
|
128
|
+
spec = importlib.util.spec_from_file_location(mod_name, info.dir / "filter.py")
|
|
129
|
+
if spec is None or spec.loader is None:
|
|
130
|
+
raise click.ClickException(
|
|
131
|
+
f"filter {info.name!r}: cannot load {info.dir / 'filter.py'}"
|
|
132
|
+
)
|
|
133
|
+
module = importlib.util.module_from_spec(spec)
|
|
134
|
+
# Re-import each time — filters are short-lived selectors; we don't
|
|
135
|
+
# want stale module state between two `filter run` calls in the same
|
|
136
|
+
# process (mostly relevant in tests).
|
|
137
|
+
sys.modules.pop(mod_name, None)
|
|
138
|
+
spec.loader.exec_module(module)
|
|
139
|
+
return module
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run_filter_py(info: FilterInfo, cfg: Config) -> list[str]:
|
|
143
|
+
"""Import filter.py and call its ``select(client)`` function.
|
|
144
|
+
|
|
145
|
+
The ``client`` callable is a thin wrapper around ``cli/_http.api()``
|
|
146
|
+
so filters share the CLI's transport (and any test-time monkeypatch
|
|
147
|
+
of ``_make_client``). Filter authors get a single function-call
|
|
148
|
+
signature instead of having to manage subprocess / HTTP themselves.
|
|
149
|
+
"""
|
|
150
|
+
module = _import_filter_module(info)
|
|
151
|
+
select = getattr(module, "select", None)
|
|
152
|
+
if not callable(select):
|
|
153
|
+
raise click.ClickException(
|
|
154
|
+
f"filter {info.name!r}: filter.py must define a callable `select(client)`"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def client(method: str, path: str, *, json_body=None, params=None):
|
|
158
|
+
return api(method, path, cfg, json_body=json_body, params=params)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
result = select(client)
|
|
162
|
+
except Exception as e: # noqa: BLE001 — surface filter author bugs as CLI errors
|
|
163
|
+
raise click.ClickException(
|
|
164
|
+
f"filter {info.name!r}: select() raised {type(e).__name__}: {e}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if not isinstance(result, list):
|
|
168
|
+
raise click.ClickException(
|
|
169
|
+
f"filter {info.name!r}: select() must return list[str], got {type(result).__name__}"
|
|
170
|
+
)
|
|
171
|
+
out: list[str] = []
|
|
172
|
+
for item in result:
|
|
173
|
+
if not isinstance(item, str):
|
|
174
|
+
raise click.ClickException(
|
|
175
|
+
f"filter {info.name!r}: select() returned non-string item: {item!r}"
|
|
176
|
+
)
|
|
177
|
+
item = item.strip()
|
|
178
|
+
if item:
|
|
179
|
+
out.append(item)
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------- mark / unmark ----------
|
|
184
|
+
|
|
185
|
+
def _subject_route(subject_id: str) -> str | None:
|
|
186
|
+
if subject_id.startswith("sess_"):
|
|
187
|
+
return f"/v2/sessions/{subject_id}/tags"
|
|
188
|
+
if subject_id.startswith("card_"):
|
|
189
|
+
return f"/v2/cards/{subject_id}/tags"
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _tag_key(tag_str: str) -> str:
|
|
194
|
+
"""Extract the key portion (left of first ':') from a key[:value] tag string."""
|
|
195
|
+
return tag_str.split(":", 1)[0].strip()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _apply_one(
|
|
199
|
+
cfg: Config, subject_id: str,
|
|
200
|
+
add_tags: list[str], remove_tags: list[str],
|
|
201
|
+
) -> dict:
|
|
202
|
+
"""Apply add+remove tag ops to one subject. Returns a result dict suitable
|
|
203
|
+
for both markdown and JSON rendering. Per-subject errors are caught and
|
|
204
|
+
surfaced in the dict, never raised."""
|
|
205
|
+
result: dict = {
|
|
206
|
+
"subject_id": subject_id,
|
|
207
|
+
"added": [],
|
|
208
|
+
"removed": [],
|
|
209
|
+
"errors": [],
|
|
210
|
+
}
|
|
211
|
+
path = _subject_route(subject_id)
|
|
212
|
+
if path is None:
|
|
213
|
+
result["errors"].append("invalid subject_id prefix (not sess_/card_)")
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
if add_tags:
|
|
217
|
+
try:
|
|
218
|
+
api("POST", path, cfg, json_body={"tags": add_tags})
|
|
219
|
+
result["added"] = list(add_tags)
|
|
220
|
+
except ApiError as e:
|
|
221
|
+
result["errors"].append(f"add: {extract_error_message(e.payload)}")
|
|
222
|
+
|
|
223
|
+
if remove_tags:
|
|
224
|
+
keys = [_tag_key(t) for t in remove_tags]
|
|
225
|
+
keys = [k for k in keys if k]
|
|
226
|
+
if keys:
|
|
227
|
+
try:
|
|
228
|
+
api("DELETE", path, cfg, params=[("key", k) for k in keys])
|
|
229
|
+
result["removed"] = list(remove_tags)
|
|
230
|
+
except ApiError as e:
|
|
231
|
+
result["errors"].append(f"remove: {extract_error_message(e.payload)}")
|
|
232
|
+
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _find_subjects_with_tag_key(cfg: Config, key: str) -> list[str]:
|
|
237
|
+
"""Use /v2/search to find sessions + cards with the given tag key."""
|
|
238
|
+
body = {"query": "", "where": f'tag = "{key}"'}
|
|
239
|
+
try:
|
|
240
|
+
resp = api("POST", "/v2/search", cfg, json_body=body)
|
|
241
|
+
except ApiError:
|
|
242
|
+
return []
|
|
243
|
+
out: list[str] = []
|
|
244
|
+
for r in (resp.get("sessions", {}).get("results") or []):
|
|
245
|
+
out.append(r["session_id"])
|
|
246
|
+
for r in (resp.get("cards", {}).get("results") or []):
|
|
247
|
+
out.append(r["card_id"])
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------- Click commands ----------
|
|
252
|
+
|
|
253
|
+
@click.group("filter")
|
|
254
|
+
def filter_() -> None:
|
|
255
|
+
"""Viewfinder over subjects: list / run / mark / unmark."""
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@filter_.command("list")
|
|
259
|
+
@click.option("--data-root", type=click.Path(), default=None)
|
|
260
|
+
@click.option("--json", "json_out", is_flag=True, default=False)
|
|
261
|
+
def filter_list(data_root: str | None, json_out: bool) -> None:
|
|
262
|
+
"""List installed filters (builtin + user)."""
|
|
263
|
+
cfg = Config(data_root) if data_root else Config()
|
|
264
|
+
filters = _discover_filters(cfg)
|
|
265
|
+
if json_out:
|
|
266
|
+
emit_json({"filters": [_filter_summary(f) for f in filters]})
|
|
267
|
+
return
|
|
268
|
+
emit_md(_fmt_filter_list(filters))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@filter_.command("run")
|
|
272
|
+
@click.argument("name")
|
|
273
|
+
@click.option("--data-root", type=click.Path(), default=None)
|
|
274
|
+
@click.option("--json", "json_out", is_flag=True, default=False)
|
|
275
|
+
def filter_run(name: str, data_root: str | None, json_out: bool) -> None:
|
|
276
|
+
"""Run filter.py and display subject_ids in frame."""
|
|
277
|
+
cfg = Config(data_root) if data_root else Config()
|
|
278
|
+
info = _resolve_filter(cfg, name)
|
|
279
|
+
subject_ids = _run_filter_py(info, cfg)
|
|
280
|
+
if json_out:
|
|
281
|
+
emit_json({"filter": info.name, "subject_ids": subject_ids})
|
|
282
|
+
return
|
|
283
|
+
emit_md(_fmt_run(info.name, subject_ids))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@filter_.command("mark")
|
|
287
|
+
@click.argument("name")
|
|
288
|
+
@click.argument("subject_ids", nargs=-1, required=True)
|
|
289
|
+
@click.option("--data-root", type=click.Path(), default=None)
|
|
290
|
+
@click.option("--json", "json_out", is_flag=True, default=False)
|
|
291
|
+
def filter_mark(name: str, subject_ids: tuple[str, ...],
|
|
292
|
+
data_root: str | None, json_out: bool) -> None:
|
|
293
|
+
"""Apply mark_tag ops (add/remove) to specific subject(s)."""
|
|
294
|
+
cfg = Config(data_root) if data_root else Config()
|
|
295
|
+
info = _resolve_filter(cfg, name)
|
|
296
|
+
add = info.meta["mark_tag"]["add"]
|
|
297
|
+
remove = info.meta["mark_tag"]["remove"]
|
|
298
|
+
|
|
299
|
+
results = [_apply_one(cfg, sid, add, remove) for sid in subject_ids]
|
|
300
|
+
_emit_apply_result(info.name, results, "mark", json_out)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@filter_.command("unmark")
|
|
304
|
+
@click.argument("name")
|
|
305
|
+
@click.argument("subject_ids", nargs=-1)
|
|
306
|
+
@click.option("--data-root", type=click.Path(), default=None)
|
|
307
|
+
@click.option("--json", "json_out", is_flag=True, default=False)
|
|
308
|
+
def filter_unmark(name: str, subject_ids: tuple[str, ...],
|
|
309
|
+
data_root: str | None, json_out: bool) -> None:
|
|
310
|
+
"""Reverse mark_tag ops. With no subject_ids, applies globally."""
|
|
311
|
+
cfg = Config(data_root) if data_root else Config()
|
|
312
|
+
info = _resolve_filter(cfg, name)
|
|
313
|
+
add = info.meta["mark_tag"]["add"]
|
|
314
|
+
remove = info.meta["mark_tag"]["remove"]
|
|
315
|
+
|
|
316
|
+
if subject_ids:
|
|
317
|
+
targets = list(subject_ids)
|
|
318
|
+
else:
|
|
319
|
+
# Global: find all subjects currently bearing any of `add`'s keys.
|
|
320
|
+
# For `remove` list, we skip global re-add (over-tag risk; doc warns).
|
|
321
|
+
seen: set[str] = set()
|
|
322
|
+
for tag in add:
|
|
323
|
+
key = _tag_key(tag)
|
|
324
|
+
for sid in _find_subjects_with_tag_key(cfg, key):
|
|
325
|
+
seen.add(sid)
|
|
326
|
+
targets = sorted(seen)
|
|
327
|
+
|
|
328
|
+
# Inverse: swap add and remove
|
|
329
|
+
results = [_apply_one(cfg, sid, remove, add) for sid in targets]
|
|
330
|
+
_emit_apply_result(info.name, results, "unmark", json_out)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------- formatters ----------
|
|
334
|
+
|
|
335
|
+
def _filter_summary(info: FilterInfo) -> dict:
|
|
336
|
+
return {
|
|
337
|
+
"name": info.name,
|
|
338
|
+
"source": info.source,
|
|
339
|
+
"mark_tag": info.meta["mark_tag"],
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _ops_label(add: list[str], remove: list[str]) -> str:
|
|
344
|
+
bits: list[str] = []
|
|
345
|
+
for t in add:
|
|
346
|
+
bits.append(f"+`{t}`")
|
|
347
|
+
for t in remove:
|
|
348
|
+
bits.append(f"-`{t}`")
|
|
349
|
+
return " ".join(bits) if bits else "—"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _fmt_filter_list(filters: list[FilterInfo]) -> str:
|
|
353
|
+
lines = [f"# filters ({len(filters)})", ""]
|
|
354
|
+
if not filters:
|
|
355
|
+
lines.append("*(none)*")
|
|
356
|
+
return "\n".join(lines) + "\n"
|
|
357
|
+
lines.append("| name | source | mark_tag |")
|
|
358
|
+
lines.append("|---|---|---|")
|
|
359
|
+
for f in filters:
|
|
360
|
+
ops = _ops_label(f.meta["mark_tag"]["add"], f.meta["mark_tag"]["remove"])
|
|
361
|
+
lines.append(f"| `{f.name}` | {f.source} | {ops} |")
|
|
362
|
+
return "\n".join(lines) + "\n"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _fmt_run(name: str, subject_ids: list[str]) -> str:
|
|
366
|
+
lines = [f"# filter run `{name}` ({len(subject_ids)})", ""]
|
|
367
|
+
if not subject_ids:
|
|
368
|
+
lines.append("*(empty frame)*")
|
|
369
|
+
return "\n".join(lines) + "\n"
|
|
370
|
+
for sid in subject_ids:
|
|
371
|
+
lines.append(f"- `{sid}`")
|
|
372
|
+
return "\n".join(lines) + "\n"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _fmt_apply(name: str, results: list[dict], action: str) -> str:
|
|
376
|
+
lines = [f"# filter {action} `{name}` ({len(results)})", ""]
|
|
377
|
+
if not results:
|
|
378
|
+
lines.append("*(no subjects)*")
|
|
379
|
+
return "\n".join(lines) + "\n"
|
|
380
|
+
for r in results:
|
|
381
|
+
ops: list[str] = []
|
|
382
|
+
for t in r["added"]:
|
|
383
|
+
ops.append(f"+`{t}`")
|
|
384
|
+
for t in r["removed"]:
|
|
385
|
+
ops.append(f"-`{t}`")
|
|
386
|
+
suffix = " ".join(ops) if ops else "(noop)"
|
|
387
|
+
line = f"- `{r['subject_id']}`: {suffix}"
|
|
388
|
+
if r["errors"]:
|
|
389
|
+
line += " **errors:** " + "; ".join(r["errors"])
|
|
390
|
+
lines.append(line)
|
|
391
|
+
return "\n".join(lines) + "\n"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _emit_apply_result(name: str, results: list[dict],
|
|
395
|
+
action: str, json_out: bool) -> None:
|
|
396
|
+
if json_out:
|
|
397
|
+
emit_json({"filter": name, "action": action, "applied": results})
|
|
398
|
+
else:
|
|
399
|
+
emit_md(_fmt_apply(name, results, action))
|
|
400
|
+
# Exit nonzero only if all subjects had errors; partial failures still exit 0
|
|
401
|
+
if results and all(r["errors"] for r in results):
|
|
402
|
+
sys.exit(1)
|