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.
- {memorytalk-0.9.2 → memorytalk-0.9.3}/PKG-INFO +1 -1
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/cards.py +23 -1
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/_format.py +24 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/card.py +67 -4
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/provider/storage.py +23 -6
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/cards.py +48 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/reviews.py +13 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/__init__.py +3 -2
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/cards.py +15 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/__init__.py +2 -2
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/cards.py +69 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/read.py +4 -4
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/PKG-INFO +1 -1
- {memorytalk-0.9.2 → memorytalk-0.9.3}/pyproject.toml +1 -1
- {memorytalk-0.9.2 → memorytalk-0.9.3}/LICENSE +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/README.md +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/__init__.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/__main__.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/__init__.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/base.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/claude_code.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/codex.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/adapters/openclaw.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/__init__.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/read.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/recall.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/reviews.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/search.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/sessions.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/status.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/api/sync.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/__init__.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/_http.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/_render.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/read.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/recall.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/review.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/search.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/server.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/session.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/setup.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/sync.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/cli/upgrade.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/config.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/claude_code/.claude-plugin/marketplace.json +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/claude_code/plugins/memory-talk-recall/.claude-plugin/plugin.json +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/claude_code/plugins/memory-talk-recall/hooks/hooks.json +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/codex/.agents/plugins/marketplace.json +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/codex/plugins/memory-talk-recall/.codex-plugin/plugin.json +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/codex/plugins/memory-talk-recall/hooks/hooks.json +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/__init__.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/base.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/claude_code.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/codex.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/materialize.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/probe.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hooks/state.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/provider/__init__.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/provider/embedding.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/provider/lancedb.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/__init__.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/recall.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/schema.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/search_log.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/sessions.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/store.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/repository/sync_checkpoint.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/card.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/read.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/recall.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/review.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/reviews.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/search.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/session.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/status.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/schemas/sync.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/server.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/backfill.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/events.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/index_buffer.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/recall.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/reviews.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/search.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/sessions.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/service/sync.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/__init__.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/console.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/dsl.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/env_template.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/formula.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/highlight.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/ids.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/indexes.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/settings_io.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/tag_filter.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/util/tags.py +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/SOURCES.txt +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/dependency_links.txt +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/entry_points.txt +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/requires.txt +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk.egg-info/top_level.txt +0 -0
- {memorytalk-0.9.2 → memorytalk-0.9.3}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
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)``
|
|
9
|
-
- ``read_text(key)``
|
|
10
|
-
- ``append_text(key, content)
|
|
11
|
-
- ``exists(key)``
|
|
12
|
-
- ``delete(key)``
|
|
13
|
-
- ``
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{memorytalk-0.9.2 → memorytalk-0.9.3}/memorytalk/hook_assets/codex/.agents/plugins/marketplace.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|