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.
Files changed (110) hide show
  1. {memorytalk-0.4.1 → memorytalk-0.4.2}/PKG-INFO +2 -1
  2. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/__init__.py +5 -1
  3. memorytalk-0.4.2/memorytalk/api/tags.py +71 -0
  4. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/__init__.py +5 -2
  5. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/_format.py +31 -5
  6. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/_http.py +3 -2
  7. memorytalk-0.4.2/memorytalk/cli/filter.py +402 -0
  8. memorytalk-0.4.2/memorytalk/cli/recall.py +122 -0
  9. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/__init__.py +50 -19
  10. memorytalk-0.4.2/memorytalk/cli/setup/steps/claude_hook.py +138 -0
  11. memorytalk-0.4.2/memorytalk/cli/setup/steps/embedding.py +200 -0
  12. memorytalk-0.4.2/memorytalk/cli/setup/steps/path_takeover.py +208 -0
  13. memorytalk-0.4.2/memorytalk/cli/setup/steps/provider.py +18 -0
  14. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/steps/server.py +23 -12
  15. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/summary.py +44 -24
  16. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/venv.py +1 -1
  17. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/wizard.py +40 -28
  18. memorytalk-0.4.2/memorytalk/cli/tag.py +88 -0
  19. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/config.py +33 -7
  20. memorytalk-0.4.2/memorytalk/filters/new-session/filter.py +28 -0
  21. memorytalk-0.4.2/memorytalk/filters/new-session/meta.json +5 -0
  22. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/provider/embedding.py +9 -16
  23. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/__init__.py +2 -0
  24. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/recall.py +13 -6
  25. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/schema.py +16 -1
  26. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/sessions.py +4 -14
  27. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/store.py +3 -1
  28. memorytalk-0.4.2/memorytalk/repository/tags.py +134 -0
  29. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/__init__.py +2 -2
  30. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/review.py +1 -0
  31. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/search.py +2 -1
  32. memorytalk-0.4.2/memorytalk/schemas/tags.py +26 -0
  33. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/view.py +3 -1
  34. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/__init__.py +2 -0
  35. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/cards.py +4 -1
  36. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/rebuild.py +41 -3
  37. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/recall.py +22 -11
  38. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/search.py +7 -3
  39. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/sessions.py +35 -55
  40. memorytalk-0.4.2/memorytalk/service/tags.py +162 -0
  41. memorytalk-0.4.2/memorytalk/util/__init__.py +0 -0
  42. memorytalk-0.4.2/memorytalk/util/console.py +156 -0
  43. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/util/dsl.py +17 -6
  44. memorytalk-0.4.2/memorytalk/util/env_template.py +45 -0
  45. memorytalk-0.4.2/memorytalk/util/settings_io.py +52 -0
  46. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/PKG-INFO +2 -1
  47. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/SOURCES.txt +11 -3
  48. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/requires.txt +1 -0
  49. {memorytalk-0.4.1 → memorytalk-0.4.2}/pyproject.toml +8 -1
  50. memorytalk-0.4.1/memorytalk/api/tags.py +0 -30
  51. memorytalk-0.4.1/memorytalk/cli/recall.py +0 -38
  52. memorytalk-0.4.1/memorytalk/cli/setup/_io.py +0 -14
  53. memorytalk-0.4.1/memorytalk/cli/setup/helpers.py +0 -121
  54. memorytalk-0.4.1/memorytalk/cli/setup/steps/alias.py +0 -30
  55. memorytalk-0.4.1/memorytalk/cli/setup/steps/embedding.py +0 -91
  56. memorytalk-0.4.1/memorytalk/cli/setup/steps/provider.py +0 -17
  57. memorytalk-0.4.1/memorytalk/cli/tag.py +0 -51
  58. memorytalk-0.4.1/memorytalk/schemas/tags.py +0 -14
  59. {memorytalk-0.4.1 → memorytalk-0.4.2}/LICENSE +0 -0
  60. {memorytalk-0.4.1 → memorytalk-0.4.2}/README.md +0 -0
  61. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/__init__.py +0 -0
  62. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/__main__.py +0 -0
  63. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/adapters/__init__.py +0 -0
  64. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/adapters/base.py +0 -0
  65. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/adapters/claude_code.py +0 -0
  66. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/cards.py +0 -0
  67. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/links.py +0 -0
  68. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/log.py +0 -0
  69. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/rebuild.py +0 -0
  70. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/recall.py +0 -0
  71. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/review.py +0 -0
  72. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/search.py +0 -0
  73. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/sessions.py +0 -0
  74. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/status.py +0 -0
  75. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/api/view.py +0 -0
  76. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/_render.py +0 -0
  77. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/card.py +0 -0
  78. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/link.py +0 -0
  79. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/log.py +0 -0
  80. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/rebuild.py +0 -0
  81. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/review.py +0 -0
  82. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/search.py +0 -0
  83. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/server.py +0 -0
  84. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/setup/steps/__init__.py +0 -0
  85. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/sync.py +0 -0
  86. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/cli/view.py +0 -0
  87. {memorytalk-0.4.1/memorytalk/provider → memorytalk-0.4.2/memorytalk/filters}/__init__.py +0 -0
  88. {memorytalk-0.4.1/memorytalk/util → memorytalk-0.4.2/memorytalk/provider}/__init__.py +0 -0
  89. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/provider/lancedb.py +0 -0
  90. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/provider/storage.py +0 -0
  91. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/cards.py +0 -0
  92. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/links.py +0 -0
  93. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/repository/search_log.py +0 -0
  94. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/cards.py +0 -0
  95. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/links.py +0 -0
  96. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/log.py +0 -0
  97. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/rebuild.py +0 -0
  98. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/recall.py +0 -0
  99. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/sessions.py +0 -0
  100. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/shared.py +0 -0
  101. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/schemas/status.py +0 -0
  102. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/events.py +0 -0
  103. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/service/links.py +0 -0
  104. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/util/ids.py +0 -0
  105. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/util/snippet.py +0 -0
  106. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk/util/ttl.py +0 -0
  107. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/dependency_links.txt +0 -0
  108. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/entry_points.txt +0 -0
  109. {memorytalk-0.4.1 → memorytalk-0.4.2}/memorytalk.egg-info/top_level.txt +0 -0
  110. {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.1
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
- main.add_command(getattr(_mod, _name))
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 == "tag_added" or kind == "tag_removed":
251
- return f"`{detail.get('tag', '')}`"
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
- out.append(f"{hit.get('rank', '?')}. `{hit.get('card_id', '')}`")
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"`{t}`" for t in tags) + "\n"
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) -> dict:
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)