keep-skill 0.3.0__py3-none-any.whl → 0.4.2__py3-none-any.whl

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.
keep/__init__.py CHANGED
@@ -40,7 +40,7 @@ if not os.environ.get("KEEP_VERBOSE"):
40
40
  from .api import Keeper, NOWDOC_ID
41
41
  from .types import Item, filter_non_system_tags, SYSTEM_TAG_PREFIX
42
42
 
43
- __version__ = "0.3.0"
43
+ __version__ = "0.4.1"
44
44
  __all__ = [
45
45
  "Keeper",
46
46
  "Item",
keep/api.py CHANGED
@@ -902,7 +902,88 @@ class Keeper:
902
902
  items = _filter_by_date(items, since)
903
903
 
904
904
  return items[:limit]
905
-
905
+
906
+ def get_similar_for_display(
907
+ self,
908
+ id: str,
909
+ *,
910
+ limit: int = 3,
911
+ collection: Optional[str] = None
912
+ ) -> list[Item]:
913
+ """
914
+ Find similar items for frontmatter display using stored embedding.
915
+
916
+ Optimized for display: uses stored embedding (no re-embedding),
917
+ filters to distinct base documents, excludes source document versions.
918
+
919
+ Args:
920
+ id: ID of item to find similar items for
921
+ limit: Maximum results to return
922
+ collection: Target collection
923
+
924
+ Returns:
925
+ List of similar items, one per unique base document
926
+ """
927
+ coll = self._resolve_collection(collection)
928
+
929
+ # Get the stored embedding (no re-embedding)
930
+ embedding = self._store.get_embedding(coll, id)
931
+ if embedding is None:
932
+ return []
933
+
934
+ # Fetch more than needed to account for version filtering
935
+ fetch_limit = limit * 3
936
+ results = self._store.query_embedding(coll, embedding, limit=fetch_limit)
937
+
938
+ # Convert to Items
939
+ items = [r.to_item() for r in results]
940
+
941
+ # Extract base ID of source document
942
+ source_base_id = id.split("@v")[0] if "@v" in id else id
943
+
944
+ # Filter to distinct base IDs, excluding source document
945
+ seen_base_ids: set[str] = set()
946
+ filtered: list[Item] = []
947
+ for item in items:
948
+ # Get base ID from tags or parse from ID
949
+ base_id = item.tags.get("_base_id", item.id.split("@v")[0] if "@v" in item.id else item.id)
950
+
951
+ # Skip versions of source document
952
+ if base_id == source_base_id:
953
+ continue
954
+
955
+ # Keep only first version of each document
956
+ if base_id not in seen_base_ids:
957
+ seen_base_ids.add(base_id)
958
+ filtered.append(item)
959
+
960
+ if len(filtered) >= limit:
961
+ break
962
+
963
+ return filtered
964
+
965
+ def get_version_offset(self, item: Item, collection: Optional[str] = None) -> int:
966
+ """
967
+ Get version offset (0=current, 1=previous, ...) for an item.
968
+
969
+ Converts the internal version number (1=oldest, 2=next...) to the
970
+ user-visible offset format (0=current, 1=previous, 2=two-ago...).
971
+
972
+ Args:
973
+ item: Item to get version offset for
974
+ collection: Target collection
975
+
976
+ Returns:
977
+ Version offset (0 for current version)
978
+ """
979
+ version_tag = item.tags.get("_version")
980
+ if not version_tag:
981
+ return 0 # Current version
982
+ base_id = item.tags.get("_base_id", item.id)
983
+ coll = self._resolve_collection(collection)
984
+ version_count = self._document_store.version_count(coll, base_id)
985
+ return version_count - int(version_tag) + 1
986
+
906
987
  def query_fulltext(
907
988
  self,
908
989
  query: str,
keep/cli.py CHANGED
@@ -9,6 +9,7 @@ Usage:
9
9
 
10
10
  import json
11
11
  import os
12
+ import re
12
13
  import sys
13
14
  from pathlib import Path
14
15
  from typing import Optional
@@ -16,6 +17,9 @@ from typing import Optional
16
17
  import typer
17
18
  from typing_extensions import Annotated
18
19
 
20
+ # Pattern for version identifier suffix: @V{N} where N is digits only
21
+ VERSION_SUFFIX_PATTERN = re.compile(r'@V\{(\d+)\}$')
22
+
19
23
  from .api import Keeper, _text_content_id
20
24
  from .document_store import VersionInfo
21
25
  from .types import Item
@@ -38,6 +42,7 @@ def _verbose_callback(value: bool):
38
42
  # Global state for CLI options
39
43
  _json_output = False
40
44
  _ids_output = False
45
+ _full_output = False
41
46
 
42
47
 
43
48
  def _json_callback(value: bool):
@@ -58,18 +63,30 @@ def _get_ids_output() -> bool:
58
63
  return _ids_output
59
64
 
60
65
 
66
+ def _full_callback(value: bool):
67
+ global _full_output
68
+ _full_output = value
69
+
70
+
71
+ def _get_full_output() -> bool:
72
+ return _full_output
73
+
74
+
61
75
  app = typer.Typer(
62
76
  name="keep",
63
77
  help="Associative memory with semantic search.",
64
78
  no_args_is_help=False,
65
79
  invoke_without_command=True,
80
+ rich_markup_mode=None,
66
81
  )
67
82
 
68
83
 
69
84
  def _format_yaml_frontmatter(
70
85
  item: Item,
71
86
  version_nav: Optional[dict[str, list[VersionInfo]]] = None,
72
- viewing_version: Optional[int] = None,
87
+ viewing_offset: Optional[int] = None,
88
+ similar_items: Optional[list[Item]] = None,
89
+ similar_offsets: Optional[dict[str, int]] = None,
73
90
  ) -> str:
74
91
  """
75
92
  Format item as YAML frontmatter with summary as content.
@@ -77,41 +94,63 @@ def _format_yaml_frontmatter(
77
94
  Args:
78
95
  item: The item to format
79
96
  version_nav: Optional version navigation info (prev/next lists)
80
- viewing_version: If viewing an old version, the version number
97
+ viewing_offset: If viewing an old version, the offset (1=previous, 2=two ago)
98
+ similar_items: Optional list of similar items to display
99
+ similar_offsets: Version offsets for similar items (item.id -> offset)
100
+
101
+ Note: Offset computation (v1, v2, etc.) assumes version_nav lists
102
+ are ordered newest-first, matching list_versions() ordering.
103
+ Changing that ordering would break the vN = -V N correspondence.
81
104
  """
82
105
  lines = ["---", f"id: {item.id}"]
83
- if viewing_version is not None:
84
- lines.append(f"version: {viewing_version}")
106
+ if viewing_offset is not None:
107
+ lines.append(f"version: {viewing_offset}")
85
108
  if item.tags:
86
- lines.append("tags:")
87
- for k, v in sorted(item.tags.items()):
88
- lines.append(f" {k}: {v}")
109
+ tag_items = ", ".join(f"{k}: {v}" for k, v in sorted(item.tags.items()))
110
+ lines.append(f"tags: {{{tag_items}}}")
89
111
  if item.score is not None:
90
112
  lines.append(f"score: {item.score:.3f}")
91
113
 
92
- # Add version navigation if available
114
+ # Add similar items if available (version-scoped IDs with date and summary)
115
+ if similar_items:
116
+ lines.append("similar:")
117
+ for sim_item in similar_items:
118
+ base_id = sim_item.tags.get("_base_id", sim_item.id)
119
+ offset = (similar_offsets or {}).get(sim_item.id, 0)
120
+ score_str = f"({sim_item.score:.2f})" if sim_item.score else ""
121
+ date_part = sim_item.tags.get("_updated", sim_item.tags.get("_created", ""))[:10]
122
+ summary_preview = sim_item.summary[:40].replace("\n", " ")
123
+ if len(sim_item.summary) > 40:
124
+ summary_preview += "..."
125
+ lines.append(f" - {base_id}@V{{{offset}}} {score_str} {date_part} {summary_preview}")
126
+
127
+ # Add version navigation (just @V{N} since ID is shown at top, with date + summary)
93
128
  if version_nav:
129
+ # Current offset (0 if viewing current)
130
+ current_offset = viewing_offset if viewing_offset is not None else 0
131
+
94
132
  if version_nav.get("prev"):
95
133
  lines.append("prev:")
96
- for v in version_nav["prev"]:
97
- # Show version number, date portion, and truncated summary
98
- date_part = v.created_at[:10] if v.created_at else "unknown"
134
+ for i, v in enumerate(version_nav["prev"]):
135
+ prev_offset = current_offset + i + 1
136
+ date_part = v.created_at[:10] if v.created_at else ""
99
137
  summary_preview = v.summary[:40].replace("\n", " ")
100
138
  if len(v.summary) > 40:
101
139
  summary_preview += "..."
102
- lines.append(f" - {v.version}: {date_part} {summary_preview}")
140
+ lines.append(f" - @V{{{prev_offset}}} {date_part} {summary_preview}")
103
141
  if version_nav.get("next"):
104
142
  lines.append("next:")
105
- for v in version_nav["next"]:
106
- date_part = v.created_at[:10] if v.created_at else "unknown"
143
+ for i, v in enumerate(version_nav["next"]):
144
+ next_offset = current_offset - i - 1
145
+ date_part = v.created_at[:10] if v.created_at else ""
107
146
  summary_preview = v.summary[:40].replace("\n", " ")
108
147
  if len(v.summary) > 40:
109
148
  summary_preview += "..."
110
- lines.append(f" - {v.version}: {date_part} {summary_preview}")
111
- elif viewing_version is not None:
149
+ lines.append(f" - @V{{{next_offset}}} {date_part} {summary_preview}")
150
+ elif viewing_offset is not None:
112
151
  # Viewing old version and next is empty means current is next
113
152
  lines.append("next:")
114
- lines.append(" - current")
153
+ lines.append(f" - @V{{0}}")
115
154
 
116
155
  lines.append("---")
117
156
  lines.append(item.summary) # Summary IS the content
@@ -139,15 +178,29 @@ def main_callback(
139
178
  callback=_ids_callback,
140
179
  is_eager=True,
141
180
  )] = False,
181
+ full_output: Annotated[bool, typer.Option(
182
+ "--full", "-F",
183
+ help="Output full items (overrides --ids)",
184
+ callback=_full_callback,
185
+ is_eager=True,
186
+ )] = False,
142
187
  ):
143
188
  """Associative memory with semantic search."""
144
189
  # If no subcommand provided, show the current context (now)
145
190
  if ctx.invoked_subcommand is None:
191
+ from .api import NOWDOC_ID
146
192
  kp = _get_keeper(None, "default")
147
193
  item = kp.get_now()
148
- typer.echo(_format_item(item, as_json=_get_json_output()))
149
- if not _get_json_output():
150
- typer.echo("\nUse --help for commands.", err=True)
194
+ version_nav = kp.get_version_nav(NOWDOC_ID, None, collection="default")
195
+ similar_items = kp.get_similar_for_display(NOWDOC_ID, limit=3, collection="default")
196
+ similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
197
+ typer.echo(_format_item(
198
+ item,
199
+ as_json=_get_json_output(),
200
+ version_nav=version_nav,
201
+ similar_items=similar_items,
202
+ similar_offsets=similar_offsets,
203
+ ))
151
204
 
152
205
 
153
206
  # -----------------------------------------------------------------------------
@@ -197,13 +250,23 @@ def _format_item(
197
250
  item: Item,
198
251
  as_json: bool = False,
199
252
  version_nav: Optional[dict[str, list[VersionInfo]]] = None,
200
- viewing_version: Optional[int] = None,
253
+ viewing_offset: Optional[int] = None,
254
+ similar_items: Optional[list[Item]] = None,
255
+ similar_offsets: Optional[dict[str, int]] = None,
201
256
  ) -> str:
202
257
  """
203
258
  Format an item for display.
204
259
 
205
260
  Text format: YAML frontmatter (matches docs/system format)
206
261
  With --ids: just the ID (for piping)
262
+
263
+ Args:
264
+ item: The item to format
265
+ as_json: Output as JSON
266
+ version_nav: Optional version navigation info (prev/next lists)
267
+ viewing_offset: If viewing an old version, the offset (1=previous, 2=two ago)
268
+ similar_items: Optional list of similar items to display
269
+ similar_offsets: Version offsets for similar items (item.id -> offset)
207
270
  """
208
271
  if _get_ids_output():
209
272
  return json.dumps(item.id) if as_json else item.id
@@ -215,17 +278,47 @@ def _format_item(
215
278
  "tags": item.tags,
216
279
  "score": item.score,
217
280
  }
218
- if viewing_version is not None:
219
- result["version"] = viewing_version
281
+ if viewing_offset is not None:
282
+ result["version"] = viewing_offset
283
+ result["vid"] = f"{item.id}@V{{{viewing_offset}}}"
284
+ if similar_items:
285
+ result["similar"] = [
286
+ {
287
+ "id": f"{s.tags.get('_base_id', s.id)}@V{{{(similar_offsets or {}).get(s.id, 0)}}}",
288
+ "score": s.score,
289
+ "date": s.tags.get("_updated", s.tags.get("_created", ""))[:10],
290
+ "summary": s.summary[:60],
291
+ }
292
+ for s in similar_items
293
+ ]
220
294
  if version_nav:
221
- result["version_nav"] = {
222
- k: [{"version": v.version, "created_at": v.created_at, "summary": v.summary[:60]}
223
- for v in versions]
224
- for k, versions in version_nav.items()
225
- }
295
+ current_offset = viewing_offset if viewing_offset is not None else 0
296
+ result["version_nav"] = {}
297
+ if version_nav.get("prev"):
298
+ result["version_nav"]["prev"] = [
299
+ {
300
+ "offset": current_offset + i + 1,
301
+ "vid": f"{item.id}@V{{{current_offset + i + 1}}}",
302
+ "created_at": v.created_at,
303
+ "summary": v.summary[:60],
304
+ }
305
+ for i, v in enumerate(version_nav["prev"])
306
+ ]
307
+ if version_nav.get("next"):
308
+ result["version_nav"]["next"] = [
309
+ {
310
+ "offset": current_offset - i - 1,
311
+ "vid": f"{item.id}@V{{{current_offset - i - 1}}}",
312
+ "created_at": v.created_at,
313
+ "summary": v.summary[:60],
314
+ }
315
+ for i, v in enumerate(version_nav["next"])
316
+ ]
317
+ elif viewing_offset is not None:
318
+ result["version_nav"]["next"] = [{"offset": 0, "vid": f"{item.id}@V{{0}}", "label": "current"}]
226
319
  return json.dumps(result)
227
320
 
228
- return _format_yaml_frontmatter(item, version_nav, viewing_version)
321
+ return _format_yaml_frontmatter(item, version_nav, viewing_offset, similar_items, similar_offsets)
229
322
 
230
323
 
231
324
  def _format_items(items: list[Item], as_json: bool = False) -> str:
@@ -338,7 +431,7 @@ def find(
338
431
 
339
432
  @app.command()
340
433
  def search(
341
- query: Annotated[str, typer.Argument(help="Full-text search query")],
434
+ query: Annotated[str, typer.Argument(default=..., help="Full-text search query")],
342
435
  store: StoreOption = None,
343
436
  collection: CollectionOption = "default",
344
437
  limit: LimitOption = 10,
@@ -364,11 +457,22 @@ def list_recent(
364
457
  """
365
458
  List recent items by update time.
366
459
 
367
- Shows the most recently updated items, newest first.
460
+ Shows IDs by default (composable). Use --full for detailed output.
368
461
  """
369
462
  kp = _get_keeper(store, collection)
370
463
  results = kp.list_recent(limit=limit)
371
- typer.echo(_format_items(results, as_json=_get_json_output()))
464
+
465
+ # Determine output mode: --full > --ids > command default (IDs for list)
466
+ if _get_json_output():
467
+ # JSON always outputs full items
468
+ typer.echo(_format_items(results, as_json=True))
469
+ elif _get_full_output():
470
+ # --full flag: full YAML output
471
+ typer.echo(_format_items(results, as_json=False))
472
+ else:
473
+ # Default for list: IDs only (composable)
474
+ for item in results:
475
+ typer.echo(item.id)
372
476
 
373
477
 
374
478
  @app.command()
@@ -429,7 +533,7 @@ def tag(
429
533
 
430
534
  @app.command("tag-update")
431
535
  def tag_update(
432
- ids: Annotated[list[str], typer.Argument(help="Document IDs to tag")],
536
+ ids: Annotated[list[str], typer.Argument(default=..., help="Document IDs to tag")],
433
537
  tags: Annotated[Optional[list[str]], typer.Option(
434
538
  "--tag", "-t",
435
539
  help="Tag as key=value (empty value removes: key=)"
@@ -605,34 +709,46 @@ def now(
605
709
  versions = kp.list_versions(NOWDOC_ID, limit=50, collection=collection)
606
710
  current = kp.get(NOWDOC_ID, collection=collection)
607
711
 
608
- if _get_json_output():
712
+ if _get_ids_output():
713
+ # Output version identifiers, one per line
714
+ if current:
715
+ typer.echo(f"{NOWDOC_ID}@V{{0}}")
716
+ for i in range(1, len(versions) + 1):
717
+ typer.echo(f"{NOWDOC_ID}@V{{{i}}}")
718
+ elif _get_json_output():
609
719
  result = {
610
720
  "id": NOWDOC_ID,
611
721
  "current": {
612
722
  "summary": current.summary if current else None,
723
+ "offset": 0,
724
+ "vid": f"{NOWDOC_ID}@V{{0}}",
613
725
  } if current else None,
614
726
  "versions": [
615
727
  {
728
+ "offset": i + 1,
729
+ "vid": f"{NOWDOC_ID}@V{{{i + 1}}}",
616
730
  "version": v.version,
617
731
  "summary": v.summary[:60],
618
732
  "created_at": v.created_at,
619
733
  }
620
- for v in versions
734
+ for i, v in enumerate(versions)
621
735
  ],
622
736
  }
623
737
  typer.echo(json.dumps(result, indent=2))
624
738
  else:
625
739
  if current:
626
740
  summary_preview = current.summary[:60].replace("\n", " ")
627
- typer.echo(f"Current: {summary_preview}...")
741
+ if len(current.summary) > 60:
742
+ summary_preview += "..."
743
+ typer.echo(f"v0 (current): {summary_preview}")
628
744
  if versions:
629
- typer.echo(f"\nVersion history ({len(versions)} archived):")
630
- for v in versions:
745
+ typer.echo(f"\nArchived:")
746
+ for i, v in enumerate(versions, start=1):
631
747
  date_part = v.created_at[:10] if v.created_at else "unknown"
632
748
  summary_preview = v.summary[:50].replace("\n", " ")
633
749
  if len(v.summary) > 50:
634
750
  summary_preview += "..."
635
- typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
751
+ typer.echo(f" v{i} ({date_part}): {summary_preview}")
636
752
  else:
637
753
  typer.echo("No version history.")
638
754
  return
@@ -642,25 +758,26 @@ def now(
642
758
  offset = version
643
759
  if offset == 0:
644
760
  item = kp.get_now()
645
- viewing_version = None
761
+ internal_version = None
646
762
  else:
647
763
  item = kp.get_version(NOWDOC_ID, offset, collection=collection)
764
+ # Get internal version number for API call
648
765
  versions = kp.list_versions(NOWDOC_ID, limit=1, collection=collection)
649
766
  if versions:
650
- viewing_version = versions[0].version - (offset - 1)
767
+ internal_version = versions[0].version - (offset - 1)
651
768
  else:
652
- viewing_version = None
769
+ internal_version = None
653
770
 
654
771
  if item is None:
655
772
  typer.echo(f"Version not found (offset {offset})", err=True)
656
773
  raise typer.Exit(1)
657
774
 
658
- version_nav = kp.get_version_nav(NOWDOC_ID, viewing_version, collection=collection)
775
+ version_nav = kp.get_version_nav(NOWDOC_ID, internal_version, collection=collection)
659
776
  typer.echo(_format_item(
660
777
  item,
661
778
  as_json=_get_json_output(),
662
779
  version_nav=version_nav,
663
- viewing_version=viewing_version,
780
+ viewing_offset=offset if offset > 0 else None,
664
781
  ))
665
782
  return
666
783
 
@@ -700,19 +817,23 @@ def now(
700
817
  item = kp.set_now(new_content, tags=parsed_tags or None)
701
818
  typer.echo(_format_item(item, as_json=_get_json_output()))
702
819
  else:
703
- # Get current context with version navigation
820
+ # Get current context with version navigation and similar items
704
821
  item = kp.get_now()
705
822
  version_nav = kp.get_version_nav(NOWDOC_ID, None, collection=collection)
823
+ similar_items = kp.get_similar_for_display(NOWDOC_ID, limit=3, collection=collection)
824
+ similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
706
825
  typer.echo(_format_item(
707
826
  item,
708
827
  as_json=_get_json_output(),
709
828
  version_nav=version_nav,
829
+ similar_items=similar_items,
830
+ similar_offsets=similar_offsets,
710
831
  ))
711
832
 
712
833
 
713
834
  @app.command()
714
835
  def get(
715
- id: Annotated[str, typer.Argument(help="URI of item to retrieve")],
836
+ id: Annotated[str, typer.Argument(default=..., help="URI of item (append @V{N} for version)")],
716
837
  version: Annotated[Optional[int], typer.Option(
717
838
  "--version", "-V",
718
839
  help="Get specific version (0=current, 1=previous, etc.)"
@@ -721,108 +842,187 @@ def get(
721
842
  "--history", "-H",
722
843
  help="List all versions"
723
844
  )] = False,
845
+ similar: Annotated[bool, typer.Option(
846
+ "--similar", "-S",
847
+ help="List similar items"
848
+ )] = False,
849
+ no_similar: Annotated[bool, typer.Option(
850
+ "--no-similar",
851
+ help="Suppress similar items in output"
852
+ )] = False,
853
+ limit: Annotated[int, typer.Option(
854
+ "--limit", "-n",
855
+ help="Max items for --history or --similar (default: 10)"
856
+ )] = 10,
724
857
  store: StoreOption = None,
725
858
  collection: CollectionOption = "default",
726
859
  ):
727
860
  """
728
861
  Retrieve a specific item by ID.
729
862
 
863
+ Version identifiers: Append @V{N} to get a specific version.
864
+
730
865
  Examples:
731
- keep get doc:1 # Current version with prev nav
866
+ keep get doc:1 # Current version with similar items
732
867
  keep get doc:1 -V 1 # Previous version with prev/next nav
868
+ keep get "doc:1@V{1}" # Same as -V 1
733
869
  keep get doc:1 --history # List all versions
870
+ keep get doc:1 --similar # List similar items
871
+ keep get doc:1 --no-similar # Suppress similar items
734
872
  """
735
873
  kp = _get_keeper(store, collection)
736
874
 
875
+ # Parse @V{N} version identifier from ID (security: check literal first)
876
+ actual_id = id
877
+ version_from_id = None
878
+
879
+ if kp.exists(id, collection=collection):
880
+ # Literal ID exists - use it directly (prevents confusion attacks)
881
+ actual_id = id
882
+ else:
883
+ # Try parsing @V{N} suffix
884
+ match = VERSION_SUFFIX_PATTERN.search(id)
885
+ if match:
886
+ version_from_id = int(match.group(1))
887
+ actual_id = id[:match.start()]
888
+
889
+ # Version from ID only applies if --version not explicitly provided
890
+ if version is None and version_from_id is not None:
891
+ version = version_from_id
892
+
737
893
  if history:
738
894
  # List all versions
739
- versions = kp.list_versions(id, limit=50, collection=collection)
740
- current = kp.get(id, collection=collection)
895
+ versions = kp.list_versions(actual_id, limit=limit, collection=collection)
896
+ current = kp.get(actual_id, collection=collection)
741
897
 
742
- if _get_json_output():
898
+ if _get_ids_output():
899
+ # Output version identifiers, one per line
900
+ if current:
901
+ typer.echo(f"{actual_id}@V{{0}}")
902
+ for i in range(1, len(versions) + 1):
903
+ typer.echo(f"{actual_id}@V{{{i}}}")
904
+ elif _get_json_output():
743
905
  result = {
744
- "id": id,
906
+ "id": actual_id,
745
907
  "current": {
746
908
  "summary": current.summary if current else None,
747
909
  "tags": current.tags if current else {},
910
+ "offset": 0,
911
+ "vid": f"{actual_id}@V{{0}}",
748
912
  } if current else None,
749
913
  "versions": [
750
914
  {
915
+ "offset": i + 1,
916
+ "vid": f"{actual_id}@V{{{i + 1}}}",
751
917
  "version": v.version,
752
918
  "summary": v.summary,
753
919
  "created_at": v.created_at,
754
920
  }
755
- for v in versions
921
+ for i, v in enumerate(versions)
756
922
  ],
757
923
  }
758
924
  typer.echo(json.dumps(result, indent=2))
759
925
  else:
760
926
  if current:
761
- typer.echo(f"Current: {current.summary[:60]}...")
927
+ summary_preview = current.summary[:60].replace("\n", " ")
928
+ if len(current.summary) > 60:
929
+ summary_preview += "..."
930
+ typer.echo(f"v0 (current): {summary_preview}")
762
931
  if versions:
763
- typer.echo(f"\nVersion history ({len(versions)} archived):")
764
- for v in versions:
932
+ typer.echo(f"\nArchived:")
933
+ for i, v in enumerate(versions, start=1):
765
934
  date_part = v.created_at[:10] if v.created_at else "unknown"
766
935
  summary_preview = v.summary[:50].replace("\n", " ")
767
936
  if len(v.summary) > 50:
768
937
  summary_preview += "..."
769
- typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
938
+ typer.echo(f" v{i} ({date_part}): {summary_preview}")
770
939
  else:
771
940
  typer.echo("No version history.")
772
941
  return
773
942
 
943
+ if similar:
944
+ # List similar items
945
+ similar_items = kp.get_similar_for_display(actual_id, limit=limit, collection=collection)
946
+ similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
947
+
948
+ if _get_ids_output():
949
+ # Output version-scoped IDs one per line
950
+ for item in similar_items:
951
+ base_id = item.tags.get("_base_id", item.id)
952
+ offset = similar_offsets.get(item.id, 0)
953
+ typer.echo(f"{base_id}@V{{{offset}}}")
954
+ elif _get_json_output():
955
+ result = {
956
+ "id": actual_id,
957
+ "similar": [
958
+ {
959
+ "id": f"{item.tags.get('_base_id', item.id)}@V{{{similar_offsets.get(item.id, 0)}}}",
960
+ "score": item.score,
961
+ "date": item.tags.get("_updated", item.tags.get("_created", ""))[:10],
962
+ "summary": item.summary[:60],
963
+ }
964
+ for item in similar_items
965
+ ],
966
+ }
967
+ typer.echo(json.dumps(result, indent=2))
968
+ else:
969
+ typer.echo(f"Similar to {actual_id}:")
970
+ if similar_items:
971
+ for item in similar_items:
972
+ base_id = item.tags.get("_base_id", item.id)
973
+ offset = similar_offsets.get(item.id, 0)
974
+ score_str = f"({item.score:.2f})" if item.score else ""
975
+ date_part = item.tags.get("_updated", item.tags.get("_created", ""))[:10]
976
+ summary_preview = item.summary[:50].replace("\n", " ")
977
+ if len(item.summary) > 50:
978
+ summary_preview += "..."
979
+ typer.echo(f" {base_id}@V{{{offset}}} {score_str} {date_part} {summary_preview}")
980
+ else:
981
+ typer.echo(" No similar items found.")
982
+ return
983
+
774
984
  # Get specific version or current
775
985
  offset = version if version is not None else 0
776
986
 
777
987
  if offset == 0:
778
- item = kp.get(id, collection=collection)
779
- viewing_version = None
988
+ item = kp.get(actual_id, collection=collection)
989
+ internal_version = None
780
990
  else:
781
- item = kp.get_version(id, offset, collection=collection)
782
- # Calculate actual version number for display
783
- versions = kp.list_versions(id, limit=1, collection=collection)
991
+ item = kp.get_version(actual_id, offset, collection=collection)
992
+ # Calculate internal version number for API call
993
+ versions = kp.list_versions(actual_id, limit=1, collection=collection)
784
994
  if versions:
785
- viewing_version = versions[0].version - (offset - 1)
995
+ internal_version = versions[0].version - (offset - 1)
786
996
  else:
787
- viewing_version = None
997
+ internal_version = None
788
998
 
789
999
  if item is None:
790
1000
  if offset > 0:
791
- typer.echo(f"Version not found: {id} (offset {offset})", err=True)
1001
+ typer.echo(f"Version not found: {actual_id} (offset {offset})", err=True)
792
1002
  else:
793
- typer.echo(f"Not found: {id}", err=True)
1003
+ typer.echo(f"Not found: {actual_id}", err=True)
794
1004
  raise typer.Exit(1)
795
1005
 
796
1006
  # Get version navigation
797
- version_nav = kp.get_version_nav(id, viewing_version, collection=collection)
1007
+ version_nav = kp.get_version_nav(actual_id, internal_version, collection=collection)
1008
+
1009
+ # Get similar items (unless suppressed or viewing old version)
1010
+ similar_items = None
1011
+ similar_offsets = None
1012
+ if not no_similar and offset == 0:
1013
+ similar_items = kp.get_similar_for_display(actual_id, limit=3, collection=collection)
1014
+ similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
798
1015
 
799
1016
  typer.echo(_format_item(
800
1017
  item,
801
1018
  as_json=_get_json_output(),
802
1019
  version_nav=version_nav,
803
- viewing_version=viewing_version,
1020
+ viewing_offset=offset if offset > 0 else None,
1021
+ similar_items=similar_items,
1022
+ similar_offsets=similar_offsets,
804
1023
  ))
805
1024
 
806
1025
 
807
- @app.command()
808
- def exists(
809
- id: Annotated[str, typer.Argument(help="URI to check")],
810
- store: StoreOption = None,
811
- collection: CollectionOption = "default",
812
- ):
813
- """
814
- Check if an item exists in the store.
815
- """
816
- kp = _get_keeper(store, collection)
817
- found = kp.exists(id)
818
-
819
- if found:
820
- typer.echo(f"Exists: {id}")
821
- else:
822
- typer.echo(f"Not found: {id}")
823
- raise typer.Exit(1)
824
-
825
-
826
1026
  @app.command("collections")
827
1027
  def list_collections(
828
1028
  store: StoreOption = None,
@@ -910,45 +1110,6 @@ def config(
910
1110
  typer.echo(f" Summarization: {cfg.summarization.name}")
911
1111
 
912
1112
 
913
- @app.command("system")
914
- def list_system(
915
- store: StoreOption = None,
916
- ):
917
- """
918
- List the system documents.
919
-
920
- Shows ID and summary for each. Use `keep get ID` for full details.
921
- """
922
- kp = _get_keeper(store, "default")
923
- docs = kp.list_system_documents()
924
-
925
- # Use --ids flag for pipe-friendly output
926
- if _get_ids_output():
927
- ids = [doc.id for doc in docs]
928
- if _get_json_output():
929
- typer.echo(json.dumps(ids))
930
- else:
931
- for doc_id in ids:
932
- typer.echo(doc_id)
933
- return
934
-
935
- if _get_json_output():
936
- typer.echo(json.dumps([
937
- {"id": doc.id, "summary": doc.summary}
938
- for doc in docs
939
- ], indent=2))
940
- else:
941
- if not docs:
942
- typer.echo("No system documents.")
943
- else:
944
- for doc in docs:
945
- # Compact summary: collapse whitespace, truncate to 70 chars
946
- summary = " ".join(doc.summary.split())[:70]
947
- if len(doc.summary) > 70:
948
- summary += "..."
949
- typer.echo(f"{doc.id}: {summary}")
950
-
951
-
952
1113
  @app.command("process-pending")
953
1114
  def process_pending(
954
1115
  store: StoreOption = None,
keep/config.py CHANGED
@@ -14,10 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Optional
15
15
 
16
16
  # tomli_w for writing TOML (tomllib is read-only)
17
- try:
18
- import tomli_w
19
- except ImportError:
20
- tomli_w = None # type: ignore
17
+ import tomli_w
21
18
 
22
19
 
23
20
  CONFIG_FILENAME = "keep.toml"
@@ -387,9 +384,6 @@ def save_config(config: StoreConfig) -> None:
387
384
 
388
385
  Creates the directory if it doesn't exist.
389
386
  """
390
- if tomli_w is None:
391
- raise RuntimeError("tomli_w is required to save config. Install with: pip install tomli-w")
392
-
393
387
  # Ensure config directory exists
394
388
  config_location = config.config_dir if config.config_dir else config.path
395
389
  config_location.mkdir(parents=True, exist_ok=True)
keep/store.py CHANGED
@@ -58,15 +58,9 @@ class ChromaStore:
58
58
  embedding_dimension: Expected dimension of embeddings (for validation).
59
59
  Can be None for read-only access; will be set on first write.
60
60
  """
61
- try:
62
- import chromadb
63
- from chromadb.config import Settings
64
- except ImportError:
65
- raise RuntimeError(
66
- "ChromaStore requires 'chromadb' library. "
67
- "Install with: pip install chromadb"
68
- )
69
-
61
+ import chromadb
62
+ from chromadb.config import Settings
63
+
70
64
  self._store_path = store_path
71
65
  self._embedding_dimension = embedding_dimension
72
66
 
@@ -382,6 +376,23 @@ class ChromaStore:
382
376
  result = coll.get(ids=[id], include=[])
383
377
  return bool(result["ids"])
384
378
 
379
+ def get_embedding(self, collection: str, id: str) -> list[float] | None:
380
+ """
381
+ Retrieve the stored embedding for a document.
382
+
383
+ Args:
384
+ collection: Collection name
385
+ id: Item identifier
386
+
387
+ Returns:
388
+ Embedding vector if found, None otherwise
389
+ """
390
+ coll = self._get_collection(collection)
391
+ result = coll.get(ids=[id], include=["embeddings"])
392
+ if not result["ids"] or result["embeddings"] is None or len(result["embeddings"]) == 0:
393
+ return None
394
+ return list(result["embeddings"][0])
395
+
385
396
  def list_ids(self, collection: str) -> list[str]:
386
397
  """
387
398
  List all document IDs in a collection.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keep-skill
3
- Version: 0.3.0
3
+ Version: 0.4.2
4
4
  Summary: Semantic memory - remember and search documents by meaning
5
5
  Project-URL: Homepage, https://github.com/hughpyle/keep
6
6
  Project-URL: Repository, https://github.com/hughpyle/keep
@@ -56,7 +56,7 @@ Description-Content-Type: text/markdown
56
56
  Index documents and notes. Search by meaning. Track changes over time.
57
57
 
58
58
  ```bash
59
- pip install 'keep-skill[local]'
59
+ uv tool install 'keep-skill[local]'
60
60
  keep init
61
61
 
62
62
  # Index content
@@ -90,16 +90,20 @@ Backed by ChromaDB for vectors, SQLite for metadata and versions.
90
90
  **Python 3.11–3.13 required.**
91
91
 
92
92
  ```bash
93
- # Recommended: local models (works offline)
94
- pip install 'keep-skill[local]'
95
-
96
- # Or with uv (faster):
93
+ # Recommended: uv (isolated environment, fast)
97
94
  uv tool install 'keep-skill[local]'
98
95
 
99
- # API-based alternative (requires OPENAI_API_KEY)
100
- pip install 'keep-skill[openai]'
96
+ # Alternative: pip in a virtual environment
97
+ python -m venv .venv && source .venv/bin/activate
98
+ pip install 'keep-skill[local]'
99
+
100
+ # API-based (requires OPENAI_API_KEY)
101
+ uv tool install 'keep-skill[openai]'
101
102
  ```
102
103
 
104
+ > **Note:** Always use an isolated environment (uv or venv). Installing with system pip
105
+ > may cause version conflicts with dependencies like typer.
106
+
103
107
  First run downloads embedding models (~3-5 minutes).
104
108
 
105
109
  ---
@@ -120,6 +124,7 @@ keep find "auth" --since P7D # Last 7 days
120
124
  # Retrieve
121
125
  keep get file:///path/to/doc.md
122
126
  keep get ID -V 1 # Previous version
127
+ keep get "ID@V{1}" # Same as -V 1 (version identifier)
123
128
  keep get ID --history # All versions
124
129
 
125
130
  # Tags
@@ -1,9 +1,9 @@
1
- keep/__init__.py,sha256=ZHguWMkzsurTDqV7_t5Nlr5VHlwVkmG6pelE0Ivfs6I,1581
1
+ keep/__init__.py,sha256=1WVkySoomQuf9-o3pIKs1CC2OwIyfkiaCxn-mO6nhd8,1581
2
2
  keep/__main__.py,sha256=3Uu70IhIDIjh8OW6jp9jQQ3dF2lKdJWi_3FtRIQMiMY,104
3
- keep/api.py,sha256=rArSGmbQkVR_x5qcMXVZcQr97daWxyettnHplgnykEM,56951
3
+ keep/api.py,sha256=7oa6jdmeE55dATOEVw18sXGVBVndjiD_7tMDcP_xl_0,59746
4
4
  keep/chunking.py,sha256=neAXOLSvVwbUxapbqq7nZrbSNSzMXuhxj-ODoOSodsU,11830
5
- keep/cli.py,sha256=yxO9FS0N1c_ffE02NV8tC-4NNj8ZNK_dkFufjqVG84A,34085
6
- keep/config.py,sha256=RRnHHvhc9KkJBYt0rpAFIvAVXw40b56xtT74TFIBiDU,15832
5
+ keep/cli.py,sha256=DzZ5Dy4U25q-Mnbv5WbfRsZaN-ped24GDk34fVddmFA,42440
6
+ keep/config.py,sha256=xhsTS_55HSzYxFNGt2z0q_0Ne-s9L9_3om8Uf_8gHB4,15643
7
7
  keep/context.py,sha256=CNpjmrv6eW2kV1E0MO6qAQfhYKRlfzAL--6v4Mj1nFY,71
8
8
  keep/document_store.py,sha256=UswqKIGSc5E-r7Tg9k0g5-byYnuar3e9FieQ7WNod9k,29109
9
9
  keep/errors.py,sha256=G9e5FbdfeugyfHOuL_SPZlM5jgWWnwsX4hM7IzanBZc,857
@@ -11,7 +11,7 @@ keep/indexing.py,sha256=dpPYo3WXnIhFDWinz5ZBZVk7_qumeNpP4EpOIY0zMbs,6063
11
11
  keep/logging_config.py,sha256=IGwkgIyg-TfYaT4MnoCXfmjeHAe_wsB_XQ1QhVT_ro8,3503
12
12
  keep/paths.py,sha256=Dv7pM6oo2QgjL6sj5wPjhuMOK2wqUkfd4Kz08TwJ1ps,3331
13
13
  keep/pending_summaries.py,sha256=_irGe7P1Lmog2c5cEgx-BElpq4YJW-tEmF5A3IUZQbQ,5727
14
- keep/store.py,sha256=RmKOAWTWvUlMcLoEbyAfR99Cxcshh_2SHhNkKYGAkqw,17509
14
+ keep/store.py,sha256=SBc2QdTyApdDDVjm2uZQI6tGbV5Hurfetgj7dyTO65o,17881
15
15
  keep/types.py,sha256=f6uOSYsYt6mj1ulKn2iRkooi__dWCiOQFPD6he2eID4,2149
16
16
  keep/providers/__init__.py,sha256=GFX_12g9OdjmpFUkTekOQBOWvcRW2Ae6yidfVVW2SiI,1095
17
17
  keep/providers/base.py,sha256=7Ug4Kj9fK2Dq4zDcZjn-GKsoZBOAlB9b-FMk969ER-g,14590
@@ -21,8 +21,8 @@ keep/providers/embeddings.py,sha256=zi8GyitKexdbCJyU1nLrUhGt_zzPn3udYrrPZ5Ak8Wo,
21
21
  keep/providers/llm.py,sha256=BxROKOklKbkGsHcSADPNNgWQExgSN6Bg4KPQIxVuB3U,12441
22
22
  keep/providers/mlx.py,sha256=aNl00r9tGi5tCGj2ArYH7CmDHtL1jLjVzb1rofU1DAo,9050
23
23
  keep/providers/summarization.py,sha256=MlVTcYipaqp2lT-QYnznp0AMuPVG36QfcTQnvY7Gb-Q,3409
24
- keep_skill-0.3.0.dist-info/METADATA,sha256=ET8OzV76N3JI_bWcJt6j6xE0uYmROmgB27U1gq9RAk0,6312
25
- keep_skill-0.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
26
- keep_skill-0.3.0.dist-info/entry_points.txt,sha256=W8yiI4kNeW0IC8ji4EHRWrvdhFxzaqTIePUhJAJAMOo,39
27
- keep_skill-0.3.0.dist-info/licenses/LICENSE,sha256=zsm0tpvtyUkevcjn5BIvs9jAho8iwxq3Ax9647AaOSg,1086
28
- keep_skill-0.3.0.dist-info/RECORD,,
24
+ keep_skill-0.4.2.dist-info/METADATA,sha256=GKkPrekD30dauxLoogAmSaoD2zhIxltJ4RDkg7yPvzk,6606
25
+ keep_skill-0.4.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
26
+ keep_skill-0.4.2.dist-info/entry_points.txt,sha256=W8yiI4kNeW0IC8ji4EHRWrvdhFxzaqTIePUhJAJAMOo,39
27
+ keep_skill-0.4.2.dist-info/licenses/LICENSE,sha256=zsm0tpvtyUkevcjn5BIvs9jAho8iwxq3Ax9647AaOSg,1086
28
+ keep_skill-0.4.2.dist-info/RECORD,,