memorytalk 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {memorytalk-0.4.0 → memorytalk-0.4.2}/PKG-INFO +2 -1
  2. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/api/__init__.py +9 -2
  3. memorytalk-0.4.2/memorytalk/api/recall.py +18 -0
  4. memorytalk-0.4.2/memorytalk/api/review.py +36 -0
  5. memorytalk-0.4.2/memorytalk/api/tags.py +71 -0
  6. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/__init__.py +5 -2
  7. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/_format.py +109 -4
  8. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/_http.py +3 -2
  9. memorytalk-0.4.2/memorytalk/cli/filter.py +402 -0
  10. memorytalk-0.4.2/memorytalk/cli/recall.py +122 -0
  11. memorytalk-0.4.2/memorytalk/cli/review.py +63 -0
  12. memorytalk-0.4.2/memorytalk/cli/setup/__init__.py +146 -0
  13. memorytalk-0.4.2/memorytalk/cli/setup/steps/claude_hook.py +138 -0
  14. memorytalk-0.4.2/memorytalk/cli/setup/steps/embedding.py +200 -0
  15. memorytalk-0.4.2/memorytalk/cli/setup/steps/path_takeover.py +208 -0
  16. memorytalk-0.4.2/memorytalk/cli/setup/steps/provider.py +18 -0
  17. memorytalk-0.4.2/memorytalk/cli/setup/steps/server.py +71 -0
  18. memorytalk-0.4.2/memorytalk/cli/setup/summary.py +128 -0
  19. memorytalk-0.4.2/memorytalk/cli/setup/venv.py +117 -0
  20. memorytalk-0.4.2/memorytalk/cli/setup/wizard.py +117 -0
  21. memorytalk-0.4.2/memorytalk/cli/tag.py +88 -0
  22. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/config.py +40 -7
  23. memorytalk-0.4.2/memorytalk/filters/new-session/filter.py +28 -0
  24. memorytalk-0.4.2/memorytalk/filters/new-session/meta.json +5 -0
  25. memorytalk-0.4.2/memorytalk/provider/__init__.py +0 -0
  26. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/provider/embedding.py +9 -16
  27. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/repository/__init__.py +4 -1
  28. memorytalk-0.4.2/memorytalk/repository/recall.py +255 -0
  29. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/repository/schema.py +38 -1
  30. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/repository/sessions.py +4 -14
  31. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/repository/store.py +5 -1
  32. memorytalk-0.4.2/memorytalk/repository/tags.py +134 -0
  33. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/__init__.py +14 -2
  34. memorytalk-0.4.2/memorytalk/schemas/recall.py +23 -0
  35. memorytalk-0.4.2/memorytalk/schemas/review.py +42 -0
  36. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/search.py +2 -1
  37. memorytalk-0.4.2/memorytalk/schemas/tags.py +26 -0
  38. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/view.py +3 -1
  39. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/service/__init__.py +4 -0
  40. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/service/cards.py +4 -1
  41. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/service/rebuild.py +41 -3
  42. memorytalk-0.4.2/memorytalk/service/recall.py +125 -0
  43. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/service/search.py +7 -3
  44. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/service/sessions.py +35 -55
  45. memorytalk-0.4.2/memorytalk/service/tags.py +162 -0
  46. memorytalk-0.4.2/memorytalk/util/__init__.py +0 -0
  47. memorytalk-0.4.2/memorytalk/util/console.py +156 -0
  48. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/util/dsl.py +17 -6
  49. memorytalk-0.4.2/memorytalk/util/env_template.py +45 -0
  50. memorytalk-0.4.2/memorytalk/util/settings_io.py +52 -0
  51. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk.egg-info/PKG-INFO +2 -1
  52. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk.egg-info/SOURCES.txt +27 -2
  53. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk.egg-info/requires.txt +1 -0
  54. {memorytalk-0.4.0 → memorytalk-0.4.2}/pyproject.toml +8 -1
  55. memorytalk-0.4.0/memorytalk/api/tags.py +0 -30
  56. memorytalk-0.4.0/memorytalk/cli/_setup_helpers.py +0 -157
  57. memorytalk-0.4.0/memorytalk/cli/setup.py +0 -429
  58. memorytalk-0.4.0/memorytalk/cli/tag.py +0 -51
  59. memorytalk-0.4.0/memorytalk/schemas/tags.py +0 -14
  60. {memorytalk-0.4.0 → memorytalk-0.4.2}/LICENSE +0 -0
  61. {memorytalk-0.4.0 → memorytalk-0.4.2}/README.md +0 -0
  62. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/__init__.py +0 -0
  63. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/__main__.py +0 -0
  64. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/adapters/__init__.py +0 -0
  65. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/adapters/base.py +0 -0
  66. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/adapters/claude_code.py +0 -0
  67. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/api/cards.py +0 -0
  68. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/api/links.py +0 -0
  69. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/api/log.py +0 -0
  70. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/api/rebuild.py +0 -0
  71. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/api/search.py +0 -0
  72. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/api/sessions.py +0 -0
  73. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/api/status.py +0 -0
  74. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/api/view.py +0 -0
  75. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/_render.py +0 -0
  76. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/card.py +0 -0
  77. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/link.py +0 -0
  78. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/log.py +0 -0
  79. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/rebuild.py +0 -0
  80. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/search.py +0 -0
  81. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/server.py +0 -0
  82. {memorytalk-0.4.0/memorytalk/provider → memorytalk-0.4.2/memorytalk/cli/setup/steps}/__init__.py +0 -0
  83. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/sync.py +0 -0
  84. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/cli/view.py +0 -0
  85. {memorytalk-0.4.0/memorytalk/util → memorytalk-0.4.2/memorytalk/filters}/__init__.py +0 -0
  86. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/provider/lancedb.py +0 -0
  87. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/provider/storage.py +0 -0
  88. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/repository/cards.py +0 -0
  89. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/repository/links.py +0 -0
  90. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/repository/search_log.py +0 -0
  91. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/cards.py +0 -0
  92. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/links.py +0 -0
  93. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/log.py +0 -0
  94. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/rebuild.py +0 -0
  95. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/sessions.py +0 -0
  96. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/shared.py +0 -0
  97. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/schemas/status.py +0 -0
  98. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/service/events.py +0 -0
  99. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/service/links.py +0 -0
  100. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/util/ids.py +0 -0
  101. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/util/snippet.py +0 -0
  102. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk/util/ttl.py +0 -0
  103. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk.egg-info/dependency_links.txt +0 -0
  104. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk.egg-info/entry_points.txt +0 -0
  105. {memorytalk-0.4.0 → memorytalk-0.4.2}/memorytalk.egg-info/top_level.txt +0 -0
  106. {memorytalk-0.4.0 → memorytalk-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memorytalk
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Persistent cross-session memory for AI agents via Talk-Card architecture (v2)
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -19,6 +19,7 @@ Requires-Dist: pyarrow>=14.0.0
19
19
  Requires-Dist: aiosqlite>=0.19.0
20
20
  Requires-Dist: aiofiles>=23.0.0
21
21
  Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: questionary>=2.0.0
22
23
  Provides-Extra: dev
23
24
  Requires-Dist: pytest>=7.4.0; extra == "dev"
24
25
  Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
@@ -15,7 +15,7 @@ from memorytalk.provider.embedding import (
15
15
  )
16
16
  from memorytalk.service import (
17
17
  CardService, EventWriter, LinkService, RebuildService,
18
- SearchService, SessionService,
18
+ RecallService, SearchService, SessionService, TagService,
19
19
  )
20
20
  from memorytalk.provider.lancedb import LanceStore
21
21
  from memorytalk.provider.storage import LocalStorage
@@ -52,8 +52,12 @@ def create_app(config: Config | None = None) -> FastAPI:
52
52
  app.state.db = db
53
53
  app.state.vectors = vectors
54
54
  app.state.embedder = embedder
55
+ # TagService must be wired before SessionService (the latter holds
56
+ # a reference so ingest can stamp `sync_session` tags).
57
+ app.state.tags = TagService(db=db, storage=storage, events=events)
55
58
  app.state.sessions = SessionService(
56
59
  config=config, db=db, vectors=vectors, events=events,
60
+ tags=app.state.tags,
57
61
  )
58
62
  app.state.cards = CardService(
59
63
  config=config, db=db, vectors=vectors, embedder=embedder, events=events,
@@ -62,6 +66,9 @@ def create_app(config: Config | None = None) -> FastAPI:
62
66
  app.state.search = SearchService(
63
67
  config=config, db=db, vectors=vectors, embedder=embedder,
64
68
  )
69
+ app.state.recall = RecallService(
70
+ config=config, db=db, vectors=vectors, embedder=embedder,
71
+ )
65
72
  app.state.rebuild = RebuildService(
66
73
  config=config, db=db, vectors=vectors, embedder=embedder,
67
74
  )
@@ -86,7 +93,7 @@ def create_app(config: Config | None = None) -> FastAPI:
86
93
  from memorytalk.api.status import router as status_router
87
94
  app.include_router(status_router, prefix="/v2")
88
95
 
89
- for name in ("sessions", "cards", "links", "tags", "search", "view", "log", "rebuild"):
96
+ for name in ("sessions", "cards", "links", "tags", "search", "recall", "review", "view", "log", "rebuild"):
90
97
  try:
91
98
  mod = __import__(f"memorytalk.api.{name}", fromlist=["router"])
92
99
  app.include_router(mod.router, prefix="/v2")
@@ -0,0 +1,18 @@
1
+ """POST /v2/recall."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from memorytalk.schemas import RecallRequest, RecallResponse
7
+ from memorytalk.service.recall import RecallError
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.post("/recall", response_model=RecallResponse)
14
+ async def post_recall(payload: RecallRequest, request: Request):
15
+ try:
16
+ return await request.app.state.recall.recall(payload)
17
+ except RecallError as e:
18
+ raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,36 @@
1
+ """GET /v2/review/list, GET /v2/review/detail/{session_id}.
2
+
3
+ Pure read-only: thin wrappers over RecallStore queries. No service-layer
4
+ class needed — review has no business logic, it's just SELECTs.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from fastapi import APIRouter, HTTPException, Query, Request
9
+
10
+ from memorytalk.schemas import ReviewDetailResponse, ReviewListResponse
11
+ from memorytalk.util.ids import prefix_session_id
12
+
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.get("/review/list", response_model=ReviewListResponse)
18
+ async def review_list(
19
+ request: Request,
20
+ limit: int = Query(100, gt=0, le=1000),
21
+ ):
22
+ rows = await request.app.state.db.recall.list_sessions(limit=limit)
23
+ return ReviewListResponse(sessions=rows)
24
+
25
+
26
+ @router.get("/review/detail/{session_id}", response_model=ReviewDetailResponse)
27
+ async def review_detail(
28
+ session_id: str,
29
+ request: Request,
30
+ limit: int = Query(50, gt=0, le=500),
31
+ ):
32
+ sid = prefix_session_id(session_id)
33
+ detail = await request.app.state.db.recall.session_detail(sid, limit=limit)
34
+ if detail is None:
35
+ raise HTTPException(status_code=404, detail="session not found in recall log")
36
+ return ReviewDetailResponse(**detail)
@@ -0,0 +1,71 @@
1
+ """Tag endpoints — resource-rooted (subject in URL path).
2
+
3
+ Sessions:
4
+ POST /v2/sessions/{session_id}/tags body: {"tags": ["k:v", ...]}
5
+ DELETE /v2/sessions/{session_id}/tags?key=k1&key=k2
6
+
7
+ Cards (commit 2 will enable):
8
+ POST /v2/cards/{card_id}/tags
9
+ DELETE /v2/cards/{card_id}/tags?key=...
10
+
11
+ Subject-id prefix validation happens inside TagService — the path param
12
+ just plumbs whatever string the URL had through. A wrong-prefix id
13
+ (e.g. ``sess_xxx`` posted to the cards route) returns 400 from the
14
+ service layer rather than a 404 from FastAPI routing.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from fastapi import APIRouter, HTTPException, Query, Request
19
+
20
+ from memorytalk.schemas import TagsAddRequest, TagsResponse
21
+ from memorytalk.service import SessionNotFound, TagServiceError
22
+
23
+
24
+ router = APIRouter()
25
+
26
+
27
+ async def _wrap_call(coro):
28
+ try:
29
+ return await coro
30
+ except SessionNotFound as e:
31
+ raise HTTPException(status_code=404, detail=str(e))
32
+ except TagServiceError as e:
33
+ raise HTTPException(status_code=400, detail=str(e))
34
+
35
+
36
+ @router.post("/sessions/{session_id}/tags", response_model=TagsResponse)
37
+ async def post_session_tags(
38
+ session_id: str, payload: TagsAddRequest, request: Request,
39
+ ) -> TagsResponse:
40
+ return await _wrap_call(
41
+ request.app.state.tags.add_tags(session_id, payload.tags)
42
+ )
43
+
44
+
45
+ @router.delete("/sessions/{session_id}/tags", response_model=TagsResponse)
46
+ async def delete_session_tags(
47
+ session_id: str, request: Request,
48
+ key: list[str] = Query(..., min_length=1),
49
+ ) -> TagsResponse:
50
+ return await _wrap_call(
51
+ request.app.state.tags.remove_tags(session_id, key)
52
+ )
53
+
54
+
55
+ @router.post("/cards/{card_id}/tags", response_model=TagsResponse)
56
+ async def post_card_tags(
57
+ card_id: str, payload: TagsAddRequest, request: Request,
58
+ ) -> TagsResponse:
59
+ return await _wrap_call(
60
+ request.app.state.tags.add_tags(card_id, payload.tags)
61
+ )
62
+
63
+
64
+ @router.delete("/cards/{card_id}/tags", response_model=TagsResponse)
65
+ async def delete_card_tags(
66
+ card_id: str, request: Request,
67
+ key: list[str] = Query(..., min_length=1),
68
+ ) -> TagsResponse:
69
+ return await _wrap_call(
70
+ request.app.state.tags.remove_tags(card_id, key)
71
+ )
@@ -13,9 +13,12 @@ def main() -> None:
13
13
  main.add_command(server)
14
14
 
15
15
  # Other command groups are attached as they're implemented.
16
- for _name in ("card", "tag", "link", "sync", "search", "view", "log", "rebuild", "setup"):
16
+ for _name in ("card", "tag", "link", "sync", "search", "recall", "review", "view", "log", "rebuild", "setup", "filter"):
17
17
  try:
18
18
  _mod = __import__(f"memorytalk.cli.{_name}", fromlist=[_name])
19
- main.add_command(getattr(_mod, _name))
19
+ # `filter` shadows a builtin → the command object is named `filter_`
20
+ # in the module; everything else uses the same name as the module.
21
+ attr = "filter_" if _name == "filter" else _name
22
+ main.add_command(getattr(_mod, attr))
20
23
  except ImportError:
21
24
  pass
@@ -141,6 +141,10 @@ def fmt_view_card(resp: dict) -> str:
141
141
  if card.get("summary"):
142
142
  out.append(f"**Summary:** {card['summary']}")
143
143
  out.append("")
144
+ tags = card.get("tags") or []
145
+ if tags:
146
+ out.append("**Tags:** " + ", ".join(f"`{_tag_label(t)}`" for t in tags))
147
+ out.append("")
144
148
 
145
149
  links = resp.get("links") or []
146
150
  out.append(f"## links ({len(links)})")
@@ -180,7 +184,7 @@ def fmt_view_session(resp: dict) -> str:
180
184
  out.append("")
181
185
  tags = sess.get("tags") or []
182
186
  if tags:
183
- out.append("**Tags:** " + ", ".join(f"`{t}`" for t in tags))
187
+ out.append("**Tags:** " + ", ".join(f"`{_tag_label(t)}`" for t in tags))
184
188
  out.append("")
185
189
  metadata = sess.get("metadata") or {}
186
190
  if metadata:
@@ -247,8 +251,18 @@ def _detail_summary(kind: str, detail: dict) -> str:
247
251
  if kind == "rounds_overwrite_skipped":
248
252
  idxs = detail.get("indexes", [])
249
253
  return f"indexes={','.join(str(i) for i in idxs)}"
250
- if kind == "tag_added" or kind == "tag_removed":
251
- return f"`{detail.get('tag', '')}`"
254
+ if kind in ("tag_added", "tag_removed"):
255
+ key = detail.get("key", "")
256
+ value = detail.get("value", "") or ""
257
+ label = f"{key}:{value}" if value else key
258
+ return f"`{label}`"
259
+ if kind == "tag_updated":
260
+ key = detail.get("key", "")
261
+ value = detail.get("value", "") or ""
262
+ prior = detail.get("prior_value", "") or ""
263
+ new_label = f"{key}:{value}" if value else key
264
+ old_label = f"{key}:{prior}" if prior else key
265
+ return f"`{old_label}` → `{new_label}`"
252
266
  if kind == "card_extracted":
253
267
  return f"`{detail.get('card_id', '')}` · indexes={detail.get('indexes', '')}"
254
268
  if kind == "linked":
@@ -321,6 +335,90 @@ def fmt_log(resp: dict) -> str:
321
335
  return fmt_error(f"unknown log type: {resp.get('type')!r}")
322
336
 
323
337
 
338
+ # ---------- review ----------
339
+
340
+ def fmt_review_list(resp: dict) -> str:
341
+ sessions = resp.get("sessions") or []
342
+ out: list[str] = []
343
+ out.append(f"# Sessions with recall history ({len(sessions)})")
344
+ out.append("")
345
+ if not sessions:
346
+ out.append("*(no recall history yet — call `memory-talk recall <session_id> <prompt>` first)*")
347
+ out.append("")
348
+ return _join(*out)
349
+ for s in sessions:
350
+ sid = s.get("session_id", "")
351
+ exist = "true" if s.get("session_exist") else "false"
352
+ rc = s.get("round_count", 0)
353
+ ci = s.get("cards_injected", 0)
354
+ last = s.get("last_at", "")
355
+ last_q = (s.get("last_query") or "").replace("\n", " ").strip()
356
+ out.append(
357
+ f"- **`{sid}`** · `session_exist={exist}` · {rc} rounds · "
358
+ f"{ci} cards · last {last}"
359
+ )
360
+ if last_q:
361
+ out.append(f" > {last_q}")
362
+ out.append("")
363
+ return _join(*out)
364
+
365
+
366
+ def fmt_review_detail(resp: dict) -> str:
367
+ sid = resp.get("session_id", "")
368
+ exist = "true" if resp.get("session_exist") else "false"
369
+ out: list[str] = []
370
+ out.append(f"# `{sid}` · session_exist={exist}")
371
+ out.append("")
372
+ out.append(
373
+ f"{resp.get('round_count', 0)} rounds · "
374
+ f"{resp.get('cards_injected', 0)} cards (deduped) · "
375
+ f"first {resp.get('first_at', '')} · last {resp.get('last_at', '')}"
376
+ )
377
+ out.append("")
378
+ rounds = resp.get("rounds") or []
379
+ for i, r in enumerate(rounds):
380
+ if i > 0:
381
+ out.append("---")
382
+ out.append("")
383
+ rc = r.get("round_count", "?")
384
+ rec_at = r.get("recalled_at", "")
385
+ out.append(f"## Round {rc} · {rec_at}")
386
+ out.append("")
387
+ q = (r.get("query") or "").replace("\n", " ").strip()
388
+ if q:
389
+ out.append(f"> {q}")
390
+ out.append("")
391
+ for hit in r.get("hits") or []:
392
+ cid = hit.get("card_id", "")
393
+ summary = (hit.get("summary") or "").replace("\n", " ").strip()
394
+ line = f"{hit.get('rank', '?')}. `{cid}`"
395
+ if summary:
396
+ line += f" — {summary}"
397
+ out.append(line)
398
+ out.append("")
399
+ return _join(*out)
400
+
401
+
402
+ # ---------- recall ----------
403
+
404
+ def fmt_recall(resp: dict) -> str:
405
+ """Bash code-block style — designed to be inlined into LLM context.
406
+
407
+ Empty hit set → empty string (per docs: no '## Memory recall (0)'
408
+ placeholder; the harness gets nothing to inject).
409
+ """
410
+ hits = resp.get("recalled") or []
411
+ if not hits:
412
+ return ""
413
+ lines = ["```bash", "# Relevant memories — run any to expand detail:"]
414
+ for h in hits:
415
+ cid = h.get("card_id", "")
416
+ summary = (h.get("summary") or "").replace("\n", " ").strip()
417
+ lines.append(f"memory-talk view {cid} # {summary}")
418
+ lines.append("```")
419
+ return _join(*lines)
420
+
421
+
324
422
  # ---------- write commands (single line ok) ----------
325
423
 
326
424
  def fmt_card_create(resp: dict) -> str:
@@ -331,11 +429,18 @@ def fmt_link_create(resp: dict) -> str:
331
429
  return f"ok: linked `{resp.get('link_id', '')}`\n"
332
430
 
333
431
 
432
+ def _tag_label(pair: dict) -> str:
433
+ """Render `{"key": "k", "value": "v"}` as ``k:v`` (or just ``k`` if value is empty)."""
434
+ key = pair.get("key", "")
435
+ value = pair.get("value", "") or ""
436
+ return f"{key}:{value}" if value else key
437
+
438
+
334
439
  def fmt_tag(resp: dict) -> str:
335
440
  tags = resp.get("tags") or []
336
441
  if not tags:
337
442
  return "ok: tags = *(empty)*\n"
338
- return "ok: tags = " + ", ".join(f"`{t}`" for t in tags) + "\n"
443
+ return "ok: tags = " + ", ".join(f"`{_tag_label(p)}`" for p in tags) + "\n"
339
444
 
340
445
 
341
446
  # ---------- sync / rebuild ----------
@@ -57,13 +57,14 @@ _make_client: Optional[Callable[[Config], httpx.Client]] = None
57
57
 
58
58
 
59
59
  def api(method: str, path: str, config: Config,
60
- json_body: dict | None = None, timeout: float = 30.0) -> dict:
60
+ json_body: dict | None = None, timeout: float = 30.0,
61
+ params: dict | list[tuple[str, str]] | None = None) -> dict:
61
62
  factory = _make_client or _default_client
62
63
  client = factory(config)
63
64
  # No `with` — ASGI test transport has no context-manager support, and
64
65
  # the CLI is a short-lived process where leaked TCP sockets get reaped
65
66
  # at exit. Tests share a long-lived ASGI client across calls.
66
- resp = client.request(method, path, json=json_body, timeout=timeout)
67
+ resp = client.request(method, path, json=json_body, timeout=timeout, params=params)
67
68
  if resp.status_code >= 400:
68
69
  try:
69
70
  payload = resp.json()