memorytalk 0.9.2__tar.gz → 0.9.3__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 (102) hide show
  1. {memorytalk-0.9.2 → memorytalk-0.9.3}/PKG-INFO +1 -1
  2. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/cards.py +23 -1
  3. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/_format.py +24 -0
  4. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/card.py +67 -4
  5. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/provider/storage.py +23 -6
  6. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/cards.py +48 -0
  7. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/reviews.py +13 -0
  8. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/__init__.py +3 -2
  9. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/cards.py +15 -0
  10. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/__init__.py +2 -2
  11. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/cards.py +69 -0
  12. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/read.py +4 -4
  13. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/PKG-INFO +1 -1
  14. {memorytalk-0.9.2 → memorytalk-0.9.3}/pyproject.toml +1 -1
  15. {memorytalk-0.9.2 → memorytalk-0.9.3}/LICENSE +0 -0
  16. {memorytalk-0.9.2 → memorytalk-0.9.3}/README.md +0 -0
  17. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/__init__.py +0 -0
  18. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/__main__.py +0 -0
  19. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/__init__.py +0 -0
  20. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/base.py +0 -0
  21. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/claude_code.py +0 -0
  22. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/codex.py +0 -0
  23. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/openclaw.py +0 -0
  24. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/__init__.py +0 -0
  25. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/read.py +0 -0
  26. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/recall.py +0 -0
  27. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/reviews.py +0 -0
  28. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/search.py +0 -0
  29. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/sessions.py +0 -0
  30. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/status.py +0 -0
  31. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/sync.py +0 -0
  32. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/__init__.py +0 -0
  33. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/_http.py +0 -0
  34. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/_render.py +0 -0
  35. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/read.py +0 -0
  36. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/recall.py +0 -0
  37. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/review.py +0 -0
  38. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/search.py +0 -0
  39. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/server.py +0 -0
  40. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/session.py +0 -0
  41. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/setup.py +0 -0
  42. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/sync.py +0 -0
  43. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/upgrade.py +0 -0
  44. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/config.py +0 -0
  45. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/claude_code/.claude-plugin/marketplace.json +0 -0
  46. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/claude_code/plugins/memory-talk-recall/.claude-plugin/plugin.json +0 -0
  47. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/claude_code/plugins/memory-talk-recall/hooks/hooks.json +0 -0
  48. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/codex/.agents/plugins/marketplace.json +0 -0
  49. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/codex/plugins/memory-talk-recall/.codex-plugin/plugin.json +0 -0
  50. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/codex/plugins/memory-talk-recall/hooks/hooks.json +0 -0
  51. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/__init__.py +0 -0
  52. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/base.py +0 -0
  53. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/claude_code.py +0 -0
  54. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/codex.py +0 -0
  55. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/materialize.py +0 -0
  56. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/probe.py +0 -0
  57. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/state.py +0 -0
  58. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/provider/__init__.py +0 -0
  59. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/provider/embedding.py +0 -0
  60. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/provider/lancedb.py +0 -0
  61. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/__init__.py +0 -0
  62. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/recall.py +0 -0
  63. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/schema.py +0 -0
  64. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/search_log.py +0 -0
  65. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/sessions.py +0 -0
  66. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/store.py +0 -0
  67. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/sync_checkpoint.py +0 -0
  68. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/card.py +0 -0
  69. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/read.py +0 -0
  70. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/recall.py +0 -0
  71. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/review.py +0 -0
  72. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/reviews.py +0 -0
  73. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/search.py +0 -0
  74. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/session.py +0 -0
  75. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/status.py +0 -0
  76. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/sync.py +0 -0
  77. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/server.py +0 -0
  78. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/backfill.py +0 -0
  79. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/events.py +0 -0
  80. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/index_buffer.py +0 -0
  81. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/recall.py +0 -0
  82. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/reviews.py +0 -0
  83. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/search.py +0 -0
  84. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/sessions.py +0 -0
  85. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/sync.py +0 -0
  86. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/__init__.py +0 -0
  87. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/console.py +0 -0
  88. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/dsl.py +0 -0
  89. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/env_template.py +0 -0
  90. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/formula.py +0 -0
  91. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/highlight.py +0 -0
  92. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/ids.py +0 -0
  93. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/indexes.py +0 -0
  94. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/settings_io.py +0 -0
  95. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/tag_filter.py +0 -0
  96. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/tags.py +0 -0
  97. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/SOURCES.txt +0 -0
  98. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/dependency_links.txt +0 -0
  99. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/entry_points.txt +0 -0
  100. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/requires.txt +0 -0
  101. {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/top_level.txt +0 -0
  102. {memorytalk-0.9.2 → memorytalk-0.9.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memorytalk
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
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
@@ -12,11 +12,12 @@ import datetime as _dt
12
12
  from fastapi import APIRouter, HTTPException, Query, Request
13
13
 
14
14
  from memorytalk.schemas import (
15
- CardListResponse, CardMeta, CardTagResponse,
15
+ CardDeleteResponse, CardListResponse, CardMeta, CardTagResponse,
16
16
  CreateCardRequest, CreateCardResponse,
17
17
  TagPatchRequest,
18
18
  )
19
19
  from memorytalk.service import CardConflict, CardServiceError
20
+ from memorytalk.service.cards import CardNotFound
20
21
  from memorytalk.util.tag_filter import parse_tag_arg
21
22
  from memorytalk.util.tags import TagValidationError, apply_patch
22
23
 
@@ -142,3 +143,24 @@ async def patch_card_tags(
142
143
  status_code=404, detail=f"card {card_id!r} not found",
143
144
  )
144
145
  return CardTagResponse(card_id=card_id, tags=merged)
146
+
147
+
148
+ @router.delete("/cards/{card_id}", response_model=CardDeleteResponse)
149
+ async def delete_card(card_id: str, request: Request) -> CardDeleteResponse:
150
+ """Hard-delete a card: SQLite row + reviews + outbound source_cards
151
+ + vector embedding + per-card filesystem dir.
152
+
153
+ Inbound source_cards (other cards that reference this one) are NOT
154
+ cascaded — they become dangling references, which the response
155
+ surfaces as ``inbound_refs_dangling`` so callers can warn the
156
+ user. recall_event rows that mention this card_id are NOT touched
157
+ — they're historical records, deleting the card doesn't rewrite
158
+ history."""
159
+ svc = request.app.state.cards
160
+ if svc is None:
161
+ raise HTTPException(status_code=503, detail="cards service unavailable")
162
+ try:
163
+ result = await svc.delete(card_id)
164
+ except CardNotFound as e:
165
+ raise HTTPException(status_code=404, detail=str(e))
166
+ return CardDeleteResponse(**result)
@@ -858,6 +858,30 @@ def fmt_card_list(payload: dict, filter_summary: str = "") -> str:
858
858
  return "\n".join(lines).rstrip() + "\n"
859
859
 
860
860
 
861
+ def fmt_card_delete(payload: dict) -> str:
862
+ """Render DELETE /v3/cards/<cid> response.
863
+
864
+ One-line confirmation + a hint about inbound-ref dangling when
865
+ applicable. We deliberately don't moan about every detail (vector
866
+ cleared / files cleared / etc.) — those are best-effort and
867
+ typically silent."""
868
+ cid = payload.get("card_id") or "?"
869
+ reviews = int(payload.get("reviews_deleted", 0) or 0)
870
+ dangling = int(payload.get("inbound_refs_dangling", 0) or 0)
871
+
872
+ bits = [f"deleted · `{cid}`"]
873
+ if reviews:
874
+ bits.append(
875
+ f"{reviews} review{'s' if reviews != 1 else ''} removed",
876
+ )
877
+ if dangling:
878
+ bits.append(
879
+ f"⚠ {dangling} inbound `source_cards` reference"
880
+ f"{'s' if dangling != 1 else ''} now dangling",
881
+ )
882
+ return " · ".join(bits) + "\n"
883
+
884
+
861
885
  def fmt_card_tag(payload: dict, *, is_query: bool) -> str:
862
886
  """Render PATCH /v3/cards/<cid>/tags response.
863
887
 
@@ -1,6 +1,6 @@
1
- """CLI: memory.talk card {create, list, tag} — card write + maintenance.
1
+ """CLI: memory.talk card {create, list, tag, delete} — card write + maintenance.
2
2
 
3
- Three subcommands:
3
+ Subcommands:
4
4
 
5
5
  create write a new immutable card (was the bare ``card '<json>'`` form
6
6
  in 0.7.x; hard-renamed in 0.8.x because the top level now hosts
@@ -8,6 +8,7 @@ Three subcommands:
8
8
  list multi-filter listing (tag / created_at) — no source/cwd: cards
9
9
  aren't from a source the way sessions are
10
10
  tag query / set / unset kv tags on one card
11
+ delete hard-delete a card (SQLite row + reviews + vector + files)
11
12
 
12
13
  The HTTP-call shape is identical to ``cli/session.py`` — both go through
13
14
  the same ``api()`` helper and share fmt_/parse_ helpers. See
@@ -20,7 +21,7 @@ import sys
20
21
  import click
21
22
 
22
23
  from memorytalk.cli._format import (
23
- fmt_card_created, fmt_card_list, fmt_card_tag, fmt_error,
24
+ fmt_card_created, fmt_card_delete, fmt_card_list, fmt_card_tag, fmt_error,
24
25
  )
25
26
  from memorytalk.cli._http import ApiError, api, extract_error_message
26
27
  from memorytalk.cli._render import emit_json, emit_json_err, emit_md, emit_md_err
@@ -33,7 +34,7 @@ from memorytalk.util.tags import TagValidationError, parse_kv_args
33
34
 
34
35
  @click.group("card")
35
36
  def card() -> None:
36
- """Card write + maintenance: create / list / tag."""
37
+ """Card write + maintenance: create / list / tag / delete."""
37
38
 
38
39
 
39
40
  # ────────── card create ──────────
@@ -159,6 +160,68 @@ def tag(card_id: str, kv_args: tuple[str, ...], json_out: bool) -> None:
159
160
  emit_md(fmt_card_tag(result, is_query=is_query))
160
161
 
161
162
 
163
+ # ────────── card delete ──────────
164
+
165
+ @card.command("delete")
166
+ @click.argument("card_id")
167
+ @click.option("--yes", "-y", is_flag=True, default=False,
168
+ help="Skip the interactive confirmation prompt.")
169
+ @click.option("--json", "json_out", is_flag=True, default=False,
170
+ help="Emit JSON")
171
+ def delete(card_id: str, yes: bool, json_out: bool) -> None:
172
+ """Hard-delete a card: SQLite row + reviews + outbound source_cards
173
+ + vector + per-card filesystem dir.
174
+
175
+ Other cards that reference this one via ``source_cards`` are NOT
176
+ cascaded — their references become dangling. ``recall_event``
177
+ history that mentions this card is NOT rewritten (history is
178
+ history). Pass ``--yes`` to skip the confirm prompt.
179
+ """
180
+ cfg = Config()
181
+
182
+ # Interactive path: pre-fetch the card to show the user WHAT they're
183
+ # about to delete (insight + created + reviews count). The DELETE
184
+ # response also surfaces those numbers, but only after the deed.
185
+ if not yes and not json_out:
186
+ try:
187
+ preview = api("POST", "/v3/read", cfg, json_body={"id": card_id})
188
+ except ApiError as e:
189
+ emit_md_err(fmt_error(extract_error_message(e.payload)))
190
+ sys.exit(1)
191
+ except Exception as e:
192
+ _emit_err(json_out, f"cannot reach server: {e}")
193
+ sys.exit(1)
194
+
195
+ card_doc = preview.get("card") or {}
196
+ reviews = card_doc.get("reviews") or []
197
+ click.echo("", err=True)
198
+ click.echo(f"card delete · {card_id}", err=True)
199
+ click.echo(f" insight: {card_doc.get('insight', '?')}", err=True)
200
+ click.echo(f" created: {card_doc.get('created_at', '?')}", err=True)
201
+ click.echo(f" reviews: {len(reviews)} (will be deleted with the card)", err=True)
202
+ click.echo("", err=True)
203
+ if not click.confirm("Delete this card?", default=False, err=True):
204
+ click.echo("aborted.", err=True)
205
+ sys.exit(1)
206
+
207
+ try:
208
+ result = api("DELETE", f"/v3/cards/{card_id}", cfg)
209
+ except ApiError as e:
210
+ if json_out:
211
+ emit_json_err(e.payload)
212
+ else:
213
+ emit_md_err(fmt_error(extract_error_message(e.payload)))
214
+ sys.exit(1)
215
+ except Exception as e:
216
+ _emit_err(json_out, f"cannot reach server: {e}")
217
+ sys.exit(1)
218
+
219
+ if json_out:
220
+ emit_json(result)
221
+ else:
222
+ emit_md(fmt_card_delete(result))
223
+
224
+
162
225
  # ────────── helpers ──────────
163
226
 
164
227
  def _summarize_filters(
@@ -5,12 +5,13 @@ domain code. Domain operations like "write a session's meta.json" live
5
5
  in ``repository/<kind>.py`` and call into Storage with full keys.
6
6
 
7
7
  Primitives:
8
- - ``write_text(key, content)`` — atomic put
9
- - ``read_text(key)`` — get; None if missing
10
- - ``append_text(key, content)``— append-only (caller pre-formats lines)
11
- - ``exists(key)`` — head
12
- - ``delete(key)`` — best-effort; missing is OK
13
- - ``list_subkeys(prefix)`` — recursive list of file keys under prefix
8
+ - ``write_text(key, content)`` — atomic put
9
+ - ``read_text(key)`` — get; None if missing
10
+ - ``append_text(key, content)`` append-only (caller pre-formats lines)
11
+ - ``exists(key)`` — head
12
+ - ``delete(key)`` — best-effort; missing is OK
13
+ - ``delete_prefix(prefix)`` — recursive rmtree; missing prefix is OK
14
+ - ``list_subkeys(prefix)`` — recursive list of file keys under prefix
14
15
 
15
16
  Keys are forward-slash strings rooted at the data root, e.g.
16
17
  ``sessions/claude-code/la/sess_lancedb/meta.json``.
@@ -29,6 +30,7 @@ class Storage(Protocol):
29
30
  async def append_text(self, key: str, content: str) -> None: ...
30
31
  async def exists(self, key: str) -> bool: ...
31
32
  async def delete(self, key: str) -> None: ...
33
+ async def delete_prefix(self, prefix: str) -> None: ...
32
34
  async def list_subkeys(self, prefix: str) -> list[str]: ...
33
35
 
34
36
 
@@ -76,6 +78,21 @@ class LocalStorage:
76
78
  except FileNotFoundError:
77
79
  pass
78
80
 
81
+ async def delete_prefix(self, prefix: str) -> None:
82
+ """Recursively remove the directory at ``prefix``. Missing prefix
83
+ is OK (no-op). Synchronous ``shutil.rmtree`` is fine here — the
84
+ IO is one syscall per inode, no fan-out worth offloading."""
85
+ import shutil
86
+ p = self._path(prefix)
87
+ if not p.exists():
88
+ return
89
+ if p.is_file():
90
+ # Defensive — caller probably meant ``delete`` for a single
91
+ # file; do the obvious thing rather than refuse.
92
+ p.unlink()
93
+ return
94
+ shutil.rmtree(p)
95
+
79
96
  async def list_subkeys(self, prefix: str) -> list[str]:
80
97
  base = self._path(prefix)
81
98
  if not base.exists():
@@ -32,6 +32,12 @@ class CardStore:
32
32
  raw = card_id[len("card_"):] if card_id.startswith("card_") else card_id
33
33
  return (raw[:2] if len(raw) >= 2 else raw).lower()
34
34
 
35
+ def _card_dir_key(self, card_id: str) -> str:
36
+ """Per-card directory prefix; used by ``delete_files`` to rmtree
37
+ the whole card footprint (card.json + events.jsonl + reviews.jsonl
38
+ + tags.json) in one shot."""
39
+ return f"{self.PREFIX}/{self._bucket(card_id)}/{card_id}"
40
+
35
41
  def _doc_key(self, card_id: str) -> str:
36
42
  return f"{self.PREFIX}/{self._bucket(card_id)}/{card_id}/card.json"
37
43
 
@@ -229,6 +235,48 @@ class CardStore:
229
235
  rows = await cursor.fetchall()
230
236
  return [{"card_id": r["source_card_id"], "relation": r["relation"]} for r in rows]
231
237
 
238
+ async def count_inbound_refs(self, card_id: str) -> int:
239
+ """How many *other* cards reference this card via source_cards
240
+ (i.e. would be left with a dangling reference if we delete it).
241
+ Covered by ``idx_csc_source``."""
242
+ async with self.conn.execute(
243
+ "SELECT COUNT(*) FROM card_source_cards WHERE source_card_id = ?",
244
+ (card_id,),
245
+ ) as cursor:
246
+ row = await cursor.fetchone()
247
+ return row[0]
248
+
249
+ # ────────── delete (0.9.x) ──────────
250
+
251
+ async def delete(self, card_id: str) -> None:
252
+ """Atomic SQLite tx removing the card row + everything 1:1 with it
253
+ (``card_stats``, outbound ``card_source_cards``). Caller is
254
+ responsible for reviews (separate store) + vector + files.
255
+
256
+ ``card_source_cards`` rows where ``source_card_id = card_id``
257
+ (inbound refs) are intentionally LEFT IN PLACE — see
258
+ ``count_inbound_refs``. The other card's "references this one"
259
+ link becomes dangling, but cascading deletes upstream would
260
+ be a destructive surprise."""
261
+ # Single transaction so a mid-flight failure leaves no
262
+ # half-state (no orphan card_stats / source_cards rows).
263
+ await self.conn.execute(
264
+ "DELETE FROM card_source_cards WHERE card_id = ?", (card_id,),
265
+ )
266
+ await self.conn.execute(
267
+ "DELETE FROM card_stats WHERE card_id = ?", (card_id,),
268
+ )
269
+ await self.conn.execute(
270
+ "DELETE FROM cards WHERE card_id = ?", (card_id,),
271
+ )
272
+ await self.conn.commit()
273
+
274
+ async def delete_files(self, card_id: str) -> None:
275
+ """Recursively remove the card's per-card directory (card.json,
276
+ events.jsonl, reviews.jsonl, tags.json). Best-effort — missing
277
+ directory is a no-op."""
278
+ await self.storage.delete_prefix(self._card_dir_key(card_id))
279
+
232
280
  # ─── 0.8.x: list + user-side tags ──────────────────────────────
233
281
 
234
282
  async def list_cards(
@@ -44,6 +44,19 @@ class ReviewStore:
44
44
  rows = await cursor.fetchall()
45
45
  return [dict(r) for r in rows]
46
46
 
47
+ async def delete_for_card(self, card_id: str) -> int:
48
+ """Delete all reviews of a card. Returns the number of rows
49
+ removed (so the service layer can report it in the response).
50
+
51
+ Used by ``CardService.delete``; reviews-of-a-deleted-card make
52
+ no sense and ``reviews.card_id`` FK has no ON DELETE CASCADE."""
53
+ async with self.conn.execute(
54
+ "DELETE FROM reviews WHERE card_id = ?", (card_id,),
55
+ ) as cursor:
56
+ n = cursor.rowcount
57
+ await self.conn.commit()
58
+ return n
59
+
47
60
  async def count(self) -> int:
48
61
  async with self.conn.execute("SELECT COUNT(*) FROM reviews") as cursor:
49
62
  row = await cursor.fetchone()
@@ -1,7 +1,7 @@
1
1
  """Pydantic schemas — request / response shapes for the v3 HTTP API."""
2
2
  from memorytalk.schemas.card import Card, CardStats, SourceCard, CardRound
3
3
  from memorytalk.schemas.cards import (
4
- CardListResponse, CardMeta, CardRoundRef, CardTagResponse,
4
+ CardDeleteResponse, CardListResponse, CardMeta, CardRoundRef, CardTagResponse,
5
5
  CreateCardRequest, CreateCardResponse,
6
6
  )
7
7
  from memorytalk.schemas.read import ReadRequest, ReadResponse
@@ -34,7 +34,8 @@ from memorytalk.schemas.sync import (
34
34
 
35
35
  __all__ = [
36
36
  "Card", "CardStats", "SourceCard", "CardRound",
37
- "CardListResponse", "CardMeta", "CardRoundRef", "CardTagResponse",
37
+ "CardDeleteResponse", "CardListResponse", "CardMeta", "CardRoundRef",
38
+ "CardTagResponse",
38
39
  "CreateCardRequest", "CreateCardResponse",
39
40
  "ReadRequest", "ReadResponse",
40
41
  "RecalledCard", "RecallRequest", "RecallResponse",
@@ -65,6 +65,21 @@ class CardListResponse(BaseModel):
65
65
  cards: list[CardMeta] = Field(default_factory=list)
66
66
 
67
67
 
68
+ class CardDeleteResponse(BaseModel):
69
+ """Response for ``DELETE /v3/cards/{card_id}``.
70
+
71
+ ``reviews_deleted`` and ``inbound_refs_dangling`` give the caller
72
+ enough information to surface the blast radius. We don't return a
73
+ ``files_deleted`` / ``vector_deleted`` because those are
74
+ best-effort cleanup; from the user's POV the card IS gone."""
75
+ card_id: str
76
+ reviews_deleted: int = 0
77
+ # Number of OTHER cards that referenced this one via source_cards.
78
+ # Those references now dangle (cards point at a missing card_id).
79
+ # Not cascaded by design — see docs/structure/v3/talk-card.md.
80
+ inbound_refs_dangling: int = 0
81
+
82
+
68
83
  class CardTagResponse(BaseModel):
69
84
  """Response of ``PATCH /v3/cards/{cid}/tags`` — full post-merge
70
85
  tag dict. Mirrors :class:`TagResponse` in shape; we keep them as
@@ -1,10 +1,10 @@
1
1
  """Service layer — orchestrates repository + provider; one class per noun."""
2
2
  from memorytalk.service.cards import (
3
- CardConflict, CardService, CardServiceError,
3
+ CardConflict, CardNotFound, CardService, CardServiceError,
4
4
  )
5
5
  from memorytalk.service.events import EventWriter
6
6
  from memorytalk.service.read import (
7
- CardNotFound, ReadService, SessionNotFound, ReadServiceError,
7
+ ReadService, SessionNotFound, ReadServiceError,
8
8
  )
9
9
  from memorytalk.service.recall import RecallService, RecallServiceError
10
10
  from memorytalk.service.reviews import (
@@ -27,6 +27,7 @@ Append-only & DAG invariants:
27
27
  """
28
28
  from __future__ import annotations
29
29
  import datetime as _dt
30
+ import logging
30
31
 
31
32
  from memorytalk.provider.embedding import Embedder
32
33
  from memorytalk.provider.lancedb import LanceStore
@@ -42,6 +43,8 @@ from memorytalk.util.tags import TagValidationError, validate_tag_dict
42
43
 
43
44
  _ALLOWED_RELATIONS = {"derives_from", "supersedes"}
44
45
 
46
+ _log = logging.getLogger(__name__)
47
+
45
48
 
46
49
  class CardServiceError(Exception):
47
50
  """4xx-equivalent: validation failed, request rejected."""
@@ -51,6 +54,10 @@ class CardConflict(CardServiceError):
51
54
  """409-equivalent: the supplied card_id already exists."""
52
55
 
53
56
 
57
+ class CardNotFound(CardServiceError):
58
+ """404-equivalent: card_id doesn't exist."""
59
+
60
+
54
61
  def _utc_iso() -> str:
55
62
  return _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
56
63
 
@@ -169,6 +176,68 @@ class CardService:
169
176
 
170
177
  return card_id
171
178
 
179
+ # ──────── delete ────────
180
+
181
+ async def delete(self, card_id: str) -> dict:
182
+ """Remove a card from SQLite + vector + file. Idempotent in the
183
+ sense that re-calling on an already-deleted id raises CardNotFound
184
+ (not a silent no-op — callers should not call us twice).
185
+
186
+ Order matters:
187
+
188
+ 1. Read summary (reviews count + inbound refs) for response.
189
+ 2. Delete reviews (separate store, no FK cascade).
190
+ 3. Delete SQLite card rows (cards + card_stats + outbound
191
+ source_cards, atomic).
192
+ 4. Delete LanceDB vector (best-effort — orphan rows are
193
+ filtered out at search time by ``card_row is None`` checks).
194
+ 5. Delete filesystem dir (best-effort — orphan dirs are
195
+ cosmetic, no read path scans them).
196
+
197
+ Steps 4 + 5 are best-effort: by the time we get there SQLite has
198
+ already committed the deletion, and reverting it would leave us
199
+ with a card the user thinks is gone but vector/files still show
200
+ up in odd places. Failures get logged; the response still says
201
+ "deleted" because from the user's POV it IS deleted. Cleanup
202
+ scripts handle the orphans later.
203
+ """
204
+ row = await self.db.cards.get(card_id)
205
+ if row is None:
206
+ raise CardNotFound(f"card {card_id} not found")
207
+
208
+ inbound = await self.db.cards.count_inbound_refs(card_id)
209
+
210
+ # 1. Reviews first — separate store, no FK cascade.
211
+ reviews_deleted = await self.db.reviews.delete_for_card(card_id)
212
+
213
+ # 2. SQLite — atomic across cards / card_stats / outbound source_cards.
214
+ await self.db.cards.delete(card_id)
215
+
216
+ # 3. Vector — best-effort.
217
+ if self.vectors is not None:
218
+ try:
219
+ await self.vectors.delete_cards([card_id])
220
+ except Exception as e: # noqa: BLE001
221
+ _log.warning(
222
+ "vector delete failed for %s; card_row is None will "
223
+ "filter orphan at search time: %s", card_id, e,
224
+ )
225
+
226
+ # 4. Files — best-effort.
227
+ try:
228
+ await self.db.cards.delete_files(card_id)
229
+ except Exception as e: # noqa: BLE001
230
+ _log.warning(
231
+ "file delete failed for %s; orphan dir is cosmetic: %s",
232
+ card_id, e,
233
+ )
234
+
235
+ return {
236
+ "card_id": card_id,
237
+ "reviews_deleted": reviews_deleted,
238
+ "inbound_refs_dangling": inbound,
239
+ }
240
+
172
241
  # ──────── helpers ────────
173
242
 
174
243
  async def _expand_rounds(self, refs) -> tuple[list[dict], list[tuple[str, str]]]:
@@ -19,6 +19,10 @@ from memorytalk.repository import SQLiteStore
19
19
  from memorytalk.schemas import (
20
20
  Card, CardRound, CardStats, ContentBlock, Review, Round, Session, SourceCard,
21
21
  )
22
+ # CardNotFound is owned by service.cards (the card service is the canonical
23
+ # place for card lifecycle errors); re-exported here for callers that
24
+ # historically imported it from service.read.
25
+ from memorytalk.service.cards import CardNotFound
22
26
  from memorytalk.service.events import EventWriter
23
27
 
24
28
 
@@ -26,10 +30,6 @@ class ReadServiceError(Exception):
26
30
  """Base for read service errors."""
27
31
 
28
32
 
29
- class CardNotFound(ReadServiceError):
30
- pass
31
-
32
-
33
33
  class SessionNotFound(ReadServiceError):
34
34
  pass
35
35
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memorytalk
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memorytalk"
3
- version = "0.9.2"
3
+ version = "0.9.3"
4
4
  description = "Persistent cross-session memory for AI agents — Talk-Card architecture with forum-dynamics sinking/floating (v3)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes
File without changes
File without changes