memorytalk 0.9.2__tar.gz → 1.0.0__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 (123) hide show
  1. {memorytalk-0.9.2 → memorytalk-1.0.0}/PKG-INFO +1 -1
  2. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/api/__init__.py +71 -49
  3. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/api/cards.py +23 -1
  4. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/api/sync.py +24 -27
  5. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/_format.py +24 -0
  6. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/card.py +67 -4
  7. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/config.py +18 -1
  8. memorytalk-1.0.0/memorytalk/migration/__init__.py +23 -0
  9. memorytalk-1.0.0/memorytalk/migration/_types.py +27 -0
  10. memorytalk-1.0.0/memorytalk/migration/discover.py +50 -0
  11. memorytalk-1.0.0/memorytalk/migration/runner.py +202 -0
  12. memorytalk-1.0.0/memorytalk/migration/state.py +140 -0
  13. memorytalk-1.0.0/memorytalk/migrations/__init__.py +15 -0
  14. memorytalk-1.0.0/memorytalk/migrations/v1/__init__.py +13 -0
  15. memorytalk-1.0.0/memorytalk/migrations/v1/init_database.py +136 -0
  16. memorytalk-1.0.0/memorytalk/migrations/v1/init_searchbase.py +20 -0
  17. memorytalk-1.0.0/memorytalk/migrations/v1/up_database.py +137 -0
  18. memorytalk-1.0.0/memorytalk/migrations/v1/up_searchbase.py +48 -0
  19. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/provider/storage.py +23 -6
  20. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/repository/cards.py +48 -0
  21. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/repository/reviews.py +13 -0
  22. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/repository/store.py +13 -6
  23. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/__init__.py +3 -2
  24. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/cards.py +15 -0
  25. memorytalk-1.0.0/memorytalk/searchbase/__init__.py +48 -0
  26. memorytalk-1.0.0/memorytalk/searchbase/_types.py +139 -0
  27. memorytalk-1.0.0/memorytalk/searchbase/local/__init__.py +0 -0
  28. memorytalk-1.0.0/memorytalk/searchbase/local/_admin.py +138 -0
  29. memorytalk-1.0.0/memorytalk/searchbase/local/_logging.py +81 -0
  30. memorytalk-1.0.0/memorytalk/searchbase/local/backend.py +237 -0
  31. memorytalk-1.0.0/memorytalk/searchbase/local/index.py +294 -0
  32. memorytalk-1.0.0/memorytalk/searchbase/local/maintenance.py +239 -0
  33. memorytalk-1.0.0/memorytalk/searchbase/local/util.py +224 -0
  34. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/__init__.py +2 -2
  35. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/backfill.py +31 -147
  36. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/cards.py +78 -10
  37. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/read.py +4 -4
  38. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/recall.py +10 -23
  39. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/search.py +27 -44
  40. memorytalk-1.0.0/memorytalk/service/searchbase_schema.py +73 -0
  41. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/sessions.py +34 -86
  42. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk.egg-info/PKG-INFO +1 -1
  43. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk.egg-info/SOURCES.txt +21 -3
  44. {memorytalk-0.9.2 → memorytalk-1.0.0}/pyproject.toml +1 -1
  45. memorytalk-0.9.2/memorytalk/provider/lancedb.py +0 -413
  46. memorytalk-0.9.2/memorytalk/repository/schema.py +0 -259
  47. memorytalk-0.9.2/memorytalk/service/index_buffer.py +0 -206
  48. {memorytalk-0.9.2 → memorytalk-1.0.0}/LICENSE +0 -0
  49. {memorytalk-0.9.2 → memorytalk-1.0.0}/README.md +0 -0
  50. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/__init__.py +0 -0
  51. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/__main__.py +0 -0
  52. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/adapters/__init__.py +0 -0
  53. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/adapters/base.py +0 -0
  54. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/adapters/claude_code.py +0 -0
  55. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/adapters/codex.py +0 -0
  56. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/adapters/openclaw.py +0 -0
  57. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/api/read.py +0 -0
  58. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/api/recall.py +0 -0
  59. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/api/reviews.py +0 -0
  60. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/api/search.py +0 -0
  61. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/api/sessions.py +0 -0
  62. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/api/status.py +0 -0
  63. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/__init__.py +0 -0
  64. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/_http.py +0 -0
  65. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/_render.py +0 -0
  66. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/read.py +0 -0
  67. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/recall.py +0 -0
  68. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/review.py +0 -0
  69. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/search.py +0 -0
  70. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/server.py +0 -0
  71. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/session.py +0 -0
  72. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/setup.py +0 -0
  73. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/sync.py +0 -0
  74. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/cli/upgrade.py +0 -0
  75. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hook_assets/claude_code/.claude-plugin/marketplace.json +0 -0
  76. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hook_assets/claude_code/plugins/memory-talk-recall/.claude-plugin/plugin.json +0 -0
  77. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hook_assets/claude_code/plugins/memory-talk-recall/hooks/hooks.json +0 -0
  78. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hook_assets/codex/.agents/plugins/marketplace.json +0 -0
  79. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hook_assets/codex/plugins/memory-talk-recall/.codex-plugin/plugin.json +0 -0
  80. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hook_assets/codex/plugins/memory-talk-recall/hooks/hooks.json +0 -0
  81. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hooks/__init__.py +0 -0
  82. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hooks/base.py +0 -0
  83. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hooks/claude_code.py +0 -0
  84. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hooks/codex.py +0 -0
  85. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hooks/materialize.py +0 -0
  86. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hooks/probe.py +0 -0
  87. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/hooks/state.py +0 -0
  88. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/provider/__init__.py +0 -0
  89. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/provider/embedding.py +0 -0
  90. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/repository/__init__.py +0 -0
  91. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/repository/recall.py +0 -0
  92. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/repository/search_log.py +0 -0
  93. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/repository/sessions.py +0 -0
  94. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/repository/sync_checkpoint.py +0 -0
  95. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/card.py +0 -0
  96. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/read.py +0 -0
  97. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/recall.py +0 -0
  98. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/review.py +0 -0
  99. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/reviews.py +0 -0
  100. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/search.py +0 -0
  101. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/session.py +0 -0
  102. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/status.py +0 -0
  103. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/schemas/sync.py +0 -0
  104. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/server.py +0 -0
  105. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/events.py +0 -0
  106. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/reviews.py +0 -0
  107. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/service/sync.py +0 -0
  108. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/__init__.py +0 -0
  109. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/console.py +0 -0
  110. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/dsl.py +0 -0
  111. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/env_template.py +0 -0
  112. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/formula.py +0 -0
  113. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/highlight.py +0 -0
  114. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/ids.py +0 -0
  115. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/indexes.py +0 -0
  116. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/settings_io.py +0 -0
  117. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/tag_filter.py +0 -0
  118. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk/util/tags.py +0 -0
  119. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk.egg-info/dependency_links.txt +0 -0
  120. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk.egg-info/entry_points.txt +0 -0
  121. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk.egg-info/requires.txt +0 -0
  122. {memorytalk-0.9.2 → memorytalk-1.0.0}/memorytalk.egg-info/top_level.txt +0 -0
  123. {memorytalk-0.9.2 → memorytalk-1.0.0}/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: 1.0.0
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
@@ -20,10 +20,10 @@ from memorytalk.config import Config, ConfigValidationError
20
20
 
21
21
 
22
22
  _log = logging.getLogger("memorytalk.api")
23
+ from memorytalk.migration import MigrationRunner
23
24
  from memorytalk.provider.embedding import (
24
- EmbedderValidationError, get_embedder, validate_embedder,
25
+ EmbedderValidationError, validate_embedder,
25
26
  )
26
- from memorytalk.provider.lancedb import LanceStore
27
27
  from memorytalk.provider.storage import LocalStorage
28
28
  from memorytalk.repository import SQLiteStore
29
29
  from memorytalk.repository.sync_checkpoint import SyncCheckpointStore
@@ -32,8 +32,8 @@ from memorytalk.service import (
32
32
  RecallService, ReviewService,
33
33
  )
34
34
  from memorytalk.service.backfill import IndexBackfill
35
- from memorytalk.service.index_buffer import IndexWriteBuffer
36
35
  from memorytalk.service.search import SearchService
36
+ from memorytalk.service.searchbase_schema import build_search_backend
37
37
  from memorytalk.service.sync import SyncWatcher
38
38
 
39
39
 
@@ -44,50 +44,76 @@ def create_app(config: Config | None = None) -> FastAPI:
44
44
  @asynccontextmanager
45
45
  async def lifespan(app: FastAPI):
46
46
  storage = LocalStorage(config.data_root)
47
- db = await SQLiteStore.create(config.db_path, storage)
48
- sync_checkpoints = await SyncCheckpointStore.create(config.sync_db_path)
49
-
50
- # LanceDB is optional at boot. If it can't open (missing pyarrow,
51
- # bad dir perms, ...) we still want read/status to work.
52
- vectors: LanceStore | None = None
53
- try:
54
- vectors = await LanceStore.create(
55
- config.vectors_dir, dim=config.settings.embedding.dim,
47
+ # Capture "is this a pre-existing install?" BEFORE we open any
48
+ # file or directory — opening the SQLite conn creates
49
+ # memory.db, and the searchbase backend populates vectors/, so
50
+ # the runner's own heuristic would fire false-positive on a
51
+ # fresh install if asked later.
52
+ existing_install = (
53
+ config.db_path.exists()
54
+ or (
55
+ config.vectors_dir.exists()
56
+ and any(config.vectors_dir.iterdir())
56
57
  )
57
- except Exception:
58
- _log.exception("lancedb init failed; vector-backed endpoints will 503")
59
-
60
- embedder = get_embedder(config)
58
+ )
59
+ # Open the raw SQLite connection (no schema yet) so the
60
+ # migration runner can apply DDL against it before we wrap it
61
+ # in a SQLiteStore.
62
+ conn = await SQLiteStore.open_connection(config.db_path)
61
63
 
62
64
  try:
63
65
  await validate_embedder(config)
64
66
  except EmbedderValidationError as e:
67
+ await conn.close()
65
68
  _log.exception("embedding startup check failed; aborting boot")
66
69
  raise SystemExit(2) from e
67
70
 
71
+ # searchbase is optional at boot. If it can't open (missing
72
+ # pyarrow, bad dir perms, ...) we still want read/status to work;
73
+ # vector-backed endpoints then return 503 ``unavailable``. The
74
+ # migration runner skips the searchbase subsystem in that case
75
+ # and picks it up on the next boot.
76
+ searchbase = None
77
+ try:
78
+ searchbase = await build_search_backend(config)
79
+ except Exception:
80
+ _log.exception("searchbase init failed; vector-backed endpoints will 503")
81
+
82
+ # Bring persistent state up to v1 (creates tables on a fresh
83
+ # install, runs the 0.8.x → v1 deltas on an upgrade). Aborts
84
+ # boot on failure — partial schemas would let services start
85
+ # and corrupt data.
86
+ runner = MigrationRunner(
87
+ db_conn=conn,
88
+ admin=searchbase.admin() if searchbase is not None else None,
89
+ state_path=config.migrations_state_path,
90
+ data_root=config.data_root,
91
+ existing_install=existing_install,
92
+ )
93
+ try:
94
+ await runner.run()
95
+ except Exception as e:
96
+ _log.exception("migration failed; aborting boot")
97
+ await conn.close()
98
+ if searchbase is not None:
99
+ try:
100
+ await searchbase.close()
101
+ except Exception:
102
+ pass
103
+ raise SystemExit(3) from e
104
+
105
+ db = SQLiteStore(conn, config.db_path, storage)
106
+ sync_checkpoints = await SyncCheckpointStore.create(config.sync_db_path)
107
+
68
108
  events = EventWriter(db)
69
109
  app.state.config = config
70
110
  app.state.storage = storage
71
111
  app.state.db = db
72
- app.state.vectors = vectors
73
- app.state.embedder = embedder
112
+ app.state.searchbase = searchbase
74
113
  app.state.events = events
75
- # IndexWriteBuffer aggregates LanceDB inserts across sessions so
76
- # one ``table.add()`` carries many embedder batches' worth of
77
- # rows. Without it the ingest path creates one fragment per
78
- # embedder batch (10 with DashScope) → vector search eventually
79
- # EMFILEs on fd ceiling. See service/index_buffer.py and
80
- # docs/issue #4 §4.3.
81
- app.state.index_buffer = IndexWriteBuffer(
82
- vectors=vectors, db=db,
83
- flush_rows=config.settings.index.lance_flush_rows,
84
- flush_interval_seconds=config.settings.index.lance_flush_interval_seconds,
85
- )
86
- app.state.index_buffer.start()
87
114
  app.state.read = ReadService(db=db, events=events)
88
115
  app.state.ingest = IngestService(
89
- db=db, vectors=vectors, embedder=embedder, events=events,
90
- index_buffer=app.state.index_buffer,
116
+ db=db, search=searchbase, events=events,
91
117
  )
92
118
  app.state.sync_checkpoints = sync_checkpoints
93
119
  app.state.sync = SyncWatcher(
@@ -95,14 +121,14 @@ def create_app(config: Config | None = None) -> FastAPI:
95
121
  checkpoints=sync_checkpoints,
96
122
  )
97
123
  app.state.search = SearchService(
98
- config=config, db=db, vectors=vectors, embedder=embedder,
124
+ config=config, db=db, search=searchbase,
99
125
  )
100
126
  app.state.cards = CardService(
101
- db=db, vectors=vectors, embedder=embedder, events=events,
127
+ db=db, search=searchbase, events=events,
102
128
  )
103
129
  app.state.reviews = ReviewService(db=db, events=events)
104
130
  app.state.recall = RecallService(
105
- config=config, db=db, vectors=vectors, embedder=embedder,
131
+ config=config, db=db, search=searchbase,
106
132
  )
107
133
 
108
134
  # Spin up the watcher if settings says so. start() returns fast
@@ -121,16 +147,12 @@ def create_app(config: Config | None = None) -> FastAPI:
121
147
  # when no embedder / lance is available; loop is cancelled on
122
148
  # lifespan shutdown.
123
149
  app.state.backfill = IndexBackfill(
124
- db=db, vectors=vectors, embedder=embedder,
125
- index_buffer=app.state.index_buffer,
150
+ db=db, search=searchbase,
126
151
  )
127
152
  app.state.backfill.start()
128
- # Guaranteed one-shot compaction on every boot grinds down the
129
- # append-only fragment pile (cause of EMFILE in vector search)
130
- # so a restart always makes progress. Side path off the re-embed
131
- # loop: gated only on vectors, runs in the background, never
132
- # blocks startup. See IndexBackfill.trigger_startup_compaction.
133
- app.state.backfill.trigger_startup_compaction()
153
+ # Startup compaction is now owned by searchbase (compact_all runs
154
+ # in the background from the backend's create()), so backfill no
155
+ # longer triggers it here.
134
156
 
135
157
  yield
136
158
 
@@ -144,12 +166,12 @@ def create_app(config: Config | None = None) -> FastAPI:
144
166
  await app.state.backfill.stop()
145
167
  except Exception:
146
168
  pass
147
- # Drain in-flight LanceDB writes before tearing down the DB
148
- # otherwise pending vectors are lost on shutdown.
149
- try:
150
- await app.state.index_buffer.stop()
151
- except Exception:
152
- pass
169
+ # Shut down the searchbase instance (stops its maintenance task).
170
+ if app.state.searchbase is not None:
171
+ try:
172
+ await app.state.searchbase.close()
173
+ except Exception:
174
+ pass
153
175
  await db.close()
154
176
  await sync_checkpoints.close()
155
177
 
@@ -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)
@@ -12,18 +12,22 @@ from fastapi import APIRouter, Query, Request
12
12
  router = APIRouter()
13
13
 
14
14
 
15
- def _gather_lance_health(state) -> dict:
16
- """Collect LanceDB-layer observability for ``index.lance``.
15
+ async def _gather_lance_health(state) -> dict:
16
+ """Collect vector-layer observability for ``index.lance``.
17
17
 
18
- Pulls from three sources: the IndexWriteBuffer (write pipeline),
19
- IndexBackfill (compaction cadence), and LanceStore (EMFILE
20
- recovery count). All fields default to safe zeros / None when the
21
- corresponding component is absent so a partially-disabled boot
22
- still returns a well-shaped response.
18
+ Pulls compaction + EMFILE-recovery state from the searchbase
19
+ instance's ``health().detail``. The write path is now immediate
20
+ (no IndexWriteBuffer), so the legacy buffer fields report idle.
21
+ All fields default to safe zeros / None when searchbase is absent
22
+ so a partially-disabled boot still returns a well-shaped response.
23
23
  """
24
- buf = getattr(state, "index_buffer", None)
25
- backfill = getattr(state, "backfill", None)
26
- vectors = getattr(state, "vectors", None)
24
+ searchbase = getattr(state, "searchbase", None)
25
+ detail: dict = {}
26
+ if searchbase is not None:
27
+ try:
28
+ detail = (await searchbase.health()).detail
29
+ except Exception:
30
+ detail = {}
27
31
 
28
32
  soft = hard = None
29
33
  try:
@@ -35,22 +39,15 @@ def _gather_lance_health(state) -> dict:
35
39
  pass
36
40
 
37
41
  return {
38
- "pending_vector_rows": (buf.pending_rows if buf is not None else 0),
39
- "last_flush_at": (buf.last_flush_at_iso if buf is not None else None),
40
- "last_flush_error": (buf.last_flush_error if buf is not None else None),
41
- "flush_count_since_boot": (buf.flush_count if buf is not None else 0),
42
- "last_compaction_at": (
43
- backfill.last_compact_at_iso if backfill is not None else None
44
- ),
45
- "last_compaction_error": (
46
- backfill.last_compact_error if backfill is not None else None
47
- ),
48
- "emfile_recoveries_since_boot": (
49
- vectors.emfile_recoveries if vectors is not None else 0
50
- ),
51
- "last_emfile_at": (
52
- vectors.last_emfile_at_iso if vectors is not None else None
53
- ),
42
+ # Writes are immediate now no buffer to drain.
43
+ "pending_vector_rows": 0,
44
+ "last_flush_at": None,
45
+ "last_flush_error": None,
46
+ "flush_count_since_boot": 0,
47
+ "last_compaction_at": detail.get("last_compact_at_iso"),
48
+ "last_compaction_error": detail.get("last_compact_error"),
49
+ "emfile_recoveries_since_boot": detail.get("emfile_recoveries", 0),
50
+ "last_emfile_at": detail.get("last_emfile_at_iso"),
54
51
  "fd_soft_limit": soft,
55
52
  "fd_hard_limit": hard,
56
53
  }
@@ -75,7 +72,7 @@ async def get_sync_status(request: Request, limit: int = Query(5, ge=0, le=20)):
75
72
  index["last_index_error"] = (
76
73
  backfill.last_error if backfill is not None else None
77
74
  )
78
- index["lance"] = _gather_lance_health(request.app.state)
75
+ index["lance"] = await _gather_lance_health(request.app.state)
79
76
 
80
77
  if not config.settings.sync.enabled:
81
78
  return {"status": "disabled", "index": index}
@@ -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(
@@ -188,6 +188,15 @@ class Config:
188
188
  def vectors_dir(self) -> Path:
189
189
  return self.data_root / "vectors"
190
190
 
191
+ @property
192
+ def migrations_state_path(self) -> Path:
193
+ """Where the migration runner tracks which (version, subsystem)
194
+ pairs have been applied. JSON file — see
195
+ ``memorytalk.migration.state``. Empty / missing on a fresh
196
+ install or on the first 0.8.x → 0.9 boot; the runner picks the
197
+ mode from that + a heuristic on ``memory.db`` existence."""
198
+ return self.data_root / "migrations_state.json"
199
+
191
200
  @property
192
201
  def sessions_dir(self) -> Path:
193
202
  return self.data_root / "sessions"
@@ -220,6 +229,14 @@ class Config:
220
229
  chatty watcher doesn't crowd out request/error logs."""
221
230
  return self.sync_log_dir / "watch.log"
222
231
 
232
+ @property
233
+ def searchbase_log_dir(self) -> Path:
234
+ """Per-category searchbase logs (maintenance / query / index).
235
+ See ``searchbase/local/_logging.py`` — three files, daily
236
+ rotation, 14-day retention. Distinct from ``search_log_dir``
237
+ (business audit, JSONL) — this one is the backend-side trail."""
238
+ return self.logs_dir / "searchbase"
239
+
223
240
  @property
224
241
  def pid_path(self) -> Path:
225
242
  return self.data_root / "server.pid"
@@ -235,7 +252,7 @@ class Config:
235
252
  for d in [
236
253
  self.data_root, self.vectors_dir, self.sessions_dir,
237
254
  self.cards_dir, self.logs_dir, self.search_log_dir,
238
- self.sync_log_dir,
255
+ self.sync_log_dir, self.searchbase_log_dir,
239
256
  ]:
240
257
  d.mkdir(parents=True, exist_ok=True)
241
258
 
@@ -0,0 +1,23 @@
1
+ """memorytalk.migration — schema-evolution runner.
2
+
3
+ The framework that brings persistent state (SQLite + searchbase) from
4
+ whatever shape it has on disk up to what the current code declares.
5
+ The version content lives in ``memorytalk.migrations`` (peer package);
6
+ this module is the runner that discovers, picks a mode, and applies.
7
+
8
+ Design: docs/works/v3/migration.md.
9
+
10
+ Public surface:
11
+
12
+ runner = MigrationRunner(
13
+ db_conn=...,
14
+ admin=backend.admin(),
15
+ state_path=config.migrations_state_path,
16
+ )
17
+ summary = await runner.run()
18
+ """
19
+ from memorytalk.migration._types import Mode, Summary
20
+ from memorytalk.migration.runner import MigrationRunner
21
+ from memorytalk.migration.state import MigrationState
22
+
23
+ __all__ = ["MigrationRunner", "MigrationState", "Mode", "Summary"]
@@ -0,0 +1,27 @@
1
+ """Value types for the migration runner."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ Mode = Literal["init_latest", "upgrade_from_zero", "catch_up"]
10
+ Subsystem = Literal["database", "searchbase"]
11
+
12
+
13
+ class Summary(BaseModel):
14
+ """What the runner did on this invocation. Returned by
15
+ :meth:`MigrationRunner.run`; useful for log lines + tests."""
16
+
17
+ mode: Mode
18
+ applied: list[tuple[str, Subsystem]] = Field(default_factory=list)
19
+ skipped: list[tuple[str, Subsystem]] = Field(default_factory=list)
20
+
21
+ @property
22
+ def applied_count(self) -> int:
23
+ return len(self.applied)
24
+
25
+ @property
26
+ def skipped_count(self) -> int:
27
+ return len(self.skipped)
@@ -0,0 +1,50 @@
1
+ """Discover migration version directories under ``memorytalk.migrations``.
2
+
3
+ The runner doesn't import migrations eagerly — they're imported lazily
4
+ when needed (``import_init`` / ``import_up``) so that a broken
5
+ migration in version N doesn't keep the runner from listing and
6
+ applying versions < N.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import pkgutil
12
+ import re
13
+
14
+
15
+ _VERSION_RE = re.compile(r"^v(\d+)$")
16
+
17
+
18
+ def discover_versions(package: str = "memorytalk.migrations") -> list[str]:
19
+ """Return all ``vN`` subpackages under ``package``, sorted by N.
20
+
21
+ Returns an empty list if the package doesn't exist (no migrations
22
+ declared yet — fresh-install case before v1 lands)."""
23
+ try:
24
+ pkg = importlib.import_module(package)
25
+ except ImportError:
26
+ return []
27
+ versions: list[tuple[int, str]] = []
28
+ for info in pkgutil.iter_modules(pkg.__path__):
29
+ if not info.ispkg:
30
+ continue
31
+ m = _VERSION_RE.match(info.name)
32
+ if m:
33
+ versions.append((int(m.group(1)), info.name))
34
+ versions.sort()
35
+ return [name for _, name in versions]
36
+
37
+
38
+ def import_migration_module(
39
+ version: str, subsystem: str, method: str,
40
+ *, package: str = "memorytalk.migrations",
41
+ ):
42
+ """Import e.g. ``memorytalk.migrations.v1.up_searchbase``. Returns
43
+ the module so the caller can call ``await module.run(handle)``.
44
+
45
+ ``method`` is ``"init"`` or ``"up"``.
46
+ ``subsystem`` is ``"database"`` or ``"searchbase"``.
47
+ """
48
+ return importlib.import_module(
49
+ f"{package}.{version}.{method}_{subsystem}",
50
+ )