keep-skill 0.3.0__py3-none-any.whl → 0.4.1__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,66 @@ 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
+
906
965
  def query_fulltext(
907
966
  self,
908
967
  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,10 @@ from typing import Optional
16
17
  import typer
17
18
  from typing_extensions import Annotated
18
19
 
20
+
21
+ # Pattern for version identifier suffix: @V{N} where N is digits only
22
+ VERSION_SUFFIX_PATTERN = re.compile(r'@V\{(\d+)\}$')
23
+
19
24
  from .api import Keeper, _text_content_id
20
25
  from .document_store import VersionInfo
21
26
  from .types import Item
@@ -38,6 +43,7 @@ def _verbose_callback(value: bool):
38
43
  # Global state for CLI options
39
44
  _json_output = False
40
45
  _ids_output = False
46
+ _full_output = False
41
47
 
42
48
 
43
49
  def _json_callback(value: bool):
@@ -58,18 +64,29 @@ def _get_ids_output() -> bool:
58
64
  return _ids_output
59
65
 
60
66
 
67
+ def _full_callback(value: bool):
68
+ global _full_output
69
+ _full_output = value
70
+
71
+
72
+ def _get_full_output() -> bool:
73
+ return _full_output
74
+
75
+
61
76
  app = typer.Typer(
62
77
  name="keep",
63
78
  help="Associative memory with semantic search.",
64
79
  no_args_is_help=False,
65
80
  invoke_without_command=True,
81
+ rich_markup_mode=None,
66
82
  )
67
83
 
68
84
 
69
85
  def _format_yaml_frontmatter(
70
86
  item: Item,
71
87
  version_nav: Optional[dict[str, list[VersionInfo]]] = None,
72
- viewing_version: Optional[int] = None,
88
+ viewing_offset: Optional[int] = None,
89
+ similar_items: Optional[list[Item]] = None,
73
90
  ) -> str:
74
91
  """
75
92
  Format item as YAML frontmatter with summary as content.
@@ -77,41 +94,58 @@ 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
+
100
+ Note: Offset computation (v1, v2, etc.) assumes version_nav lists
101
+ are ordered newest-first, matching list_versions() ordering.
102
+ Changing that ordering would break the vN = -V N correspondence.
81
103
  """
82
104
  lines = ["---", f"id: {item.id}"]
83
- if viewing_version is not None:
84
- lines.append(f"version: {viewing_version}")
105
+ if viewing_offset is not None:
106
+ lines.append(f"version: {viewing_offset}")
85
107
  if item.tags:
86
- lines.append("tags:")
87
- for k, v in sorted(item.tags.items()):
88
- lines.append(f" {k}: {v}")
108
+ tag_items = ", ".join(f"{k}: {v}" for k, v in sorted(item.tags.items()))
109
+ lines.append(f"tags: {{{tag_items}}}")
89
110
  if item.score is not None:
90
111
  lines.append(f"score: {item.score:.3f}")
91
112
 
113
+ # Add similar items if available
114
+ if similar_items:
115
+ lines.append("similar:")
116
+ for sim_item in similar_items:
117
+ score_str = f"({sim_item.score:.2f})" if sim_item.score else ""
118
+ lines.append(f" - {sim_item.id} {score_str}")
119
+
92
120
  # Add version navigation if available
93
121
  if version_nav:
122
+ # Current offset (0 if viewing current)
123
+ current_offset = viewing_offset if viewing_offset is not None else 0
124
+
94
125
  if version_nav.get("prev"):
95
126
  lines.append("prev:")
96
- for v in version_nav["prev"]:
97
- # Show version number, date portion, and truncated summary
127
+ for i, v in enumerate(version_nav["prev"]):
128
+ # Offset for this prev item: current_offset + i + 1
129
+ prev_offset = current_offset + i + 1
98
130
  date_part = v.created_at[:10] if v.created_at else "unknown"
99
131
  summary_preview = v.summary[:40].replace("\n", " ")
100
132
  if len(v.summary) > 40:
101
133
  summary_preview += "..."
102
- lines.append(f" - {v.version}: {date_part} {summary_preview}")
134
+ lines.append(f" - v{prev_offset}: {date_part} {summary_preview}")
103
135
  if version_nav.get("next"):
104
136
  lines.append("next:")
105
- for v in version_nav["next"]:
137
+ for i, v in enumerate(version_nav["next"]):
138
+ # Offset for this next item: current_offset - i - 1
139
+ next_offset = current_offset - i - 1
106
140
  date_part = v.created_at[:10] if v.created_at else "unknown"
107
141
  summary_preview = v.summary[:40].replace("\n", " ")
108
142
  if len(v.summary) > 40:
109
143
  summary_preview += "..."
110
- lines.append(f" - {v.version}: {date_part} {summary_preview}")
111
- elif viewing_version is not None:
144
+ lines.append(f" - v{next_offset}: {date_part} {summary_preview}")
145
+ elif viewing_offset is not None:
112
146
  # Viewing old version and next is empty means current is next
113
147
  lines.append("next:")
114
- lines.append(" - current")
148
+ lines.append(" - v0 (current)")
115
149
 
116
150
  lines.append("---")
117
151
  lines.append(item.summary) # Summary IS the content
@@ -139,15 +173,27 @@ def main_callback(
139
173
  callback=_ids_callback,
140
174
  is_eager=True,
141
175
  )] = False,
176
+ full_output: Annotated[bool, typer.Option(
177
+ "--full", "-F",
178
+ help="Output full items (overrides --ids)",
179
+ callback=_full_callback,
180
+ is_eager=True,
181
+ )] = False,
142
182
  ):
143
183
  """Associative memory with semantic search."""
144
184
  # If no subcommand provided, show the current context (now)
145
185
  if ctx.invoked_subcommand is None:
186
+ from .api import NOWDOC_ID
146
187
  kp = _get_keeper(None, "default")
147
188
  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)
189
+ version_nav = kp.get_version_nav(NOWDOC_ID, None, collection="default")
190
+ similar_items = kp.get_similar_for_display(NOWDOC_ID, limit=3, collection="default")
191
+ typer.echo(_format_item(
192
+ item,
193
+ as_json=_get_json_output(),
194
+ version_nav=version_nav,
195
+ similar_items=similar_items,
196
+ ))
151
197
 
152
198
 
153
199
  # -----------------------------------------------------------------------------
@@ -197,13 +243,21 @@ def _format_item(
197
243
  item: Item,
198
244
  as_json: bool = False,
199
245
  version_nav: Optional[dict[str, list[VersionInfo]]] = None,
200
- viewing_version: Optional[int] = None,
246
+ viewing_offset: Optional[int] = None,
247
+ similar_items: Optional[list[Item]] = None,
201
248
  ) -> str:
202
249
  """
203
250
  Format an item for display.
204
251
 
205
252
  Text format: YAML frontmatter (matches docs/system format)
206
253
  With --ids: just the ID (for piping)
254
+
255
+ Args:
256
+ item: The item to format
257
+ as_json: Output as JSON
258
+ version_nav: Optional version navigation info (prev/next lists)
259
+ viewing_offset: If viewing an old version, the offset (1=previous, 2=two ago)
260
+ similar_items: Optional list of similar items to display
207
261
  """
208
262
  if _get_ids_output():
209
263
  return json.dumps(item.id) if as_json else item.id
@@ -215,17 +269,42 @@ def _format_item(
215
269
  "tags": item.tags,
216
270
  "score": item.score,
217
271
  }
218
- if viewing_version is not None:
219
- result["version"] = viewing_version
272
+ if viewing_offset is not None:
273
+ result["version"] = viewing_offset
274
+ result["vid"] = f"{item.id}@V{{{viewing_offset}}}"
275
+ if similar_items:
276
+ result["similar"] = [
277
+ {"id": s.id, "score": s.score, "summary": s.summary[:60]}
278
+ for s in similar_items
279
+ ]
220
280
  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
- }
281
+ current_offset = viewing_offset if viewing_offset is not None else 0
282
+ result["version_nav"] = {}
283
+ if version_nav.get("prev"):
284
+ result["version_nav"]["prev"] = [
285
+ {
286
+ "offset": current_offset + i + 1,
287
+ "vid": f"{item.id}@V{{{current_offset + i + 1}}}",
288
+ "created_at": v.created_at,
289
+ "summary": v.summary[:60],
290
+ }
291
+ for i, v in enumerate(version_nav["prev"])
292
+ ]
293
+ if version_nav.get("next"):
294
+ result["version_nav"]["next"] = [
295
+ {
296
+ "offset": current_offset - i - 1,
297
+ "vid": f"{item.id}@V{{{current_offset - i - 1}}}",
298
+ "created_at": v.created_at,
299
+ "summary": v.summary[:60],
300
+ }
301
+ for i, v in enumerate(version_nav["next"])
302
+ ]
303
+ elif viewing_offset is not None:
304
+ result["version_nav"]["next"] = [{"offset": 0, "vid": f"{item.id}@V{{0}}", "label": "current"}]
226
305
  return json.dumps(result)
227
306
 
228
- return _format_yaml_frontmatter(item, version_nav, viewing_version)
307
+ return _format_yaml_frontmatter(item, version_nav, viewing_offset, similar_items)
229
308
 
230
309
 
231
310
  def _format_items(items: list[Item], as_json: bool = False) -> str:
@@ -364,11 +443,22 @@ def list_recent(
364
443
  """
365
444
  List recent items by update time.
366
445
 
367
- Shows the most recently updated items, newest first.
446
+ Shows IDs by default (composable). Use --full for detailed output.
368
447
  """
369
448
  kp = _get_keeper(store, collection)
370
449
  results = kp.list_recent(limit=limit)
371
- typer.echo(_format_items(results, as_json=_get_json_output()))
450
+
451
+ # Determine output mode: --full > --ids > command default (IDs for list)
452
+ if _get_json_output():
453
+ # JSON always outputs full items
454
+ typer.echo(_format_items(results, as_json=True))
455
+ elif _get_full_output():
456
+ # --full flag: full YAML output
457
+ typer.echo(_format_items(results, as_json=False))
458
+ else:
459
+ # Default for list: IDs only (composable)
460
+ for item in results:
461
+ typer.echo(item.id)
372
462
 
373
463
 
374
464
  @app.command()
@@ -605,34 +695,46 @@ def now(
605
695
  versions = kp.list_versions(NOWDOC_ID, limit=50, collection=collection)
606
696
  current = kp.get(NOWDOC_ID, collection=collection)
607
697
 
608
- if _get_json_output():
698
+ if _get_ids_output():
699
+ # Output version identifiers, one per line
700
+ if current:
701
+ typer.echo(f"{NOWDOC_ID}@V{{0}}")
702
+ for i in range(1, len(versions) + 1):
703
+ typer.echo(f"{NOWDOC_ID}@V{{{i}}}")
704
+ elif _get_json_output():
609
705
  result = {
610
706
  "id": NOWDOC_ID,
611
707
  "current": {
612
708
  "summary": current.summary if current else None,
709
+ "offset": 0,
710
+ "vid": f"{NOWDOC_ID}@V{{0}}",
613
711
  } if current else None,
614
712
  "versions": [
615
713
  {
714
+ "offset": i + 1,
715
+ "vid": f"{NOWDOC_ID}@V{{{i + 1}}}",
616
716
  "version": v.version,
617
717
  "summary": v.summary[:60],
618
718
  "created_at": v.created_at,
619
719
  }
620
- for v in versions
720
+ for i, v in enumerate(versions)
621
721
  ],
622
722
  }
623
723
  typer.echo(json.dumps(result, indent=2))
624
724
  else:
625
725
  if current:
626
726
  summary_preview = current.summary[:60].replace("\n", " ")
627
- typer.echo(f"Current: {summary_preview}...")
727
+ if len(current.summary) > 60:
728
+ summary_preview += "..."
729
+ typer.echo(f"v0 (current): {summary_preview}")
628
730
  if versions:
629
- typer.echo(f"\nVersion history ({len(versions)} archived):")
630
- for v in versions:
731
+ typer.echo(f"\nArchived:")
732
+ for i, v in enumerate(versions, start=1):
631
733
  date_part = v.created_at[:10] if v.created_at else "unknown"
632
734
  summary_preview = v.summary[:50].replace("\n", " ")
633
735
  if len(v.summary) > 50:
634
736
  summary_preview += "..."
635
- typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
737
+ typer.echo(f" v{i} ({date_part}): {summary_preview}")
636
738
  else:
637
739
  typer.echo("No version history.")
638
740
  return
@@ -642,25 +744,26 @@ def now(
642
744
  offset = version
643
745
  if offset == 0:
644
746
  item = kp.get_now()
645
- viewing_version = None
747
+ internal_version = None
646
748
  else:
647
749
  item = kp.get_version(NOWDOC_ID, offset, collection=collection)
750
+ # Get internal version number for API call
648
751
  versions = kp.list_versions(NOWDOC_ID, limit=1, collection=collection)
649
752
  if versions:
650
- viewing_version = versions[0].version - (offset - 1)
753
+ internal_version = versions[0].version - (offset - 1)
651
754
  else:
652
- viewing_version = None
755
+ internal_version = None
653
756
 
654
757
  if item is None:
655
758
  typer.echo(f"Version not found (offset {offset})", err=True)
656
759
  raise typer.Exit(1)
657
760
 
658
- version_nav = kp.get_version_nav(NOWDOC_ID, viewing_version, collection=collection)
761
+ version_nav = kp.get_version_nav(NOWDOC_ID, internal_version, collection=collection)
659
762
  typer.echo(_format_item(
660
763
  item,
661
764
  as_json=_get_json_output(),
662
765
  version_nav=version_nav,
663
- viewing_version=viewing_version,
766
+ viewing_offset=offset if offset > 0 else None,
664
767
  ))
665
768
  return
666
769
 
@@ -700,19 +803,21 @@ def now(
700
803
  item = kp.set_now(new_content, tags=parsed_tags or None)
701
804
  typer.echo(_format_item(item, as_json=_get_json_output()))
702
805
  else:
703
- # Get current context with version navigation
806
+ # Get current context with version navigation and similar items
704
807
  item = kp.get_now()
705
808
  version_nav = kp.get_version_nav(NOWDOC_ID, None, collection=collection)
809
+ similar_items = kp.get_similar_for_display(NOWDOC_ID, limit=3, collection=collection)
706
810
  typer.echo(_format_item(
707
811
  item,
708
812
  as_json=_get_json_output(),
709
813
  version_nav=version_nav,
814
+ similar_items=similar_items,
710
815
  ))
711
816
 
712
817
 
713
818
  @app.command()
714
819
  def get(
715
- id: Annotated[str, typer.Argument(help="URI of item to retrieve")],
820
+ id: Annotated[str, typer.Argument(help="URI of item (append @V{N} for version)")],
716
821
  version: Annotated[Optional[int], typer.Option(
717
822
  "--version", "-V",
718
823
  help="Get specific version (0=current, 1=previous, etc.)"
@@ -721,108 +826,177 @@ def get(
721
826
  "--history", "-H",
722
827
  help="List all versions"
723
828
  )] = False,
829
+ similar: Annotated[bool, typer.Option(
830
+ "--similar", "-S",
831
+ help="List similar items"
832
+ )] = False,
833
+ no_similar: Annotated[bool, typer.Option(
834
+ "--no-similar",
835
+ help="Suppress similar items in output"
836
+ )] = False,
837
+ limit: Annotated[int, typer.Option(
838
+ "--limit", "-n",
839
+ help="Max items for --history or --similar (default: 10)"
840
+ )] = 10,
724
841
  store: StoreOption = None,
725
842
  collection: CollectionOption = "default",
726
843
  ):
727
844
  """
728
845
  Retrieve a specific item by ID.
729
846
 
847
+ Version identifiers: Append @V{N} to get a specific version.
848
+
730
849
  Examples:
731
- keep get doc:1 # Current version with prev nav
850
+ keep get doc:1 # Current version with similar items
732
851
  keep get doc:1 -V 1 # Previous version with prev/next nav
852
+ keep get "doc:1@V{1}" # Same as -V 1
733
853
  keep get doc:1 --history # List all versions
854
+ keep get doc:1 --similar # List similar items
855
+ keep get doc:1 --no-similar # Suppress similar items
734
856
  """
735
857
  kp = _get_keeper(store, collection)
736
858
 
859
+ # Parse @V{N} version identifier from ID (security: check literal first)
860
+ actual_id = id
861
+ version_from_id = None
862
+
863
+ if kp.exists(id, collection=collection):
864
+ # Literal ID exists - use it directly (prevents confusion attacks)
865
+ actual_id = id
866
+ else:
867
+ # Try parsing @V{N} suffix
868
+ match = VERSION_SUFFIX_PATTERN.search(id)
869
+ if match:
870
+ version_from_id = int(match.group(1))
871
+ actual_id = id[:match.start()]
872
+
873
+ # Version from ID only applies if --version not explicitly provided
874
+ if version is None and version_from_id is not None:
875
+ version = version_from_id
876
+
737
877
  if history:
738
878
  # List all versions
739
- versions = kp.list_versions(id, limit=50, collection=collection)
740
- current = kp.get(id, collection=collection)
879
+ versions = kp.list_versions(actual_id, limit=limit, collection=collection)
880
+ current = kp.get(actual_id, collection=collection)
741
881
 
742
- if _get_json_output():
882
+ if _get_ids_output():
883
+ # Output version identifiers, one per line
884
+ if current:
885
+ typer.echo(f"{actual_id}@V{{0}}")
886
+ for i in range(1, len(versions) + 1):
887
+ typer.echo(f"{actual_id}@V{{{i}}}")
888
+ elif _get_json_output():
743
889
  result = {
744
- "id": id,
890
+ "id": actual_id,
745
891
  "current": {
746
892
  "summary": current.summary if current else None,
747
893
  "tags": current.tags if current else {},
894
+ "offset": 0,
895
+ "vid": f"{actual_id}@V{{0}}",
748
896
  } if current else None,
749
897
  "versions": [
750
898
  {
899
+ "offset": i + 1,
900
+ "vid": f"{actual_id}@V{{{i + 1}}}",
751
901
  "version": v.version,
752
902
  "summary": v.summary,
753
903
  "created_at": v.created_at,
754
904
  }
755
- for v in versions
905
+ for i, v in enumerate(versions)
756
906
  ],
757
907
  }
758
908
  typer.echo(json.dumps(result, indent=2))
759
909
  else:
760
910
  if current:
761
- typer.echo(f"Current: {current.summary[:60]}...")
911
+ summary_preview = current.summary[:60].replace("\n", " ")
912
+ if len(current.summary) > 60:
913
+ summary_preview += "..."
914
+ typer.echo(f"v0 (current): {summary_preview}")
762
915
  if versions:
763
- typer.echo(f"\nVersion history ({len(versions)} archived):")
764
- for v in versions:
916
+ typer.echo(f"\nArchived:")
917
+ for i, v in enumerate(versions, start=1):
765
918
  date_part = v.created_at[:10] if v.created_at else "unknown"
766
919
  summary_preview = v.summary[:50].replace("\n", " ")
767
920
  if len(v.summary) > 50:
768
921
  summary_preview += "..."
769
- typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
922
+ typer.echo(f" v{i} ({date_part}): {summary_preview}")
770
923
  else:
771
924
  typer.echo("No version history.")
772
925
  return
773
926
 
927
+ if similar:
928
+ # List similar items
929
+ similar_items = kp.get_similar_for_display(actual_id, limit=limit, collection=collection)
930
+
931
+ if _get_ids_output():
932
+ # Output IDs one per line
933
+ for item in similar_items:
934
+ typer.echo(item.id)
935
+ elif _get_json_output():
936
+ result = {
937
+ "id": actual_id,
938
+ "similar": [
939
+ {
940
+ "id": item.id,
941
+ "score": item.score,
942
+ "summary": item.summary[:60],
943
+ }
944
+ for item in similar_items
945
+ ],
946
+ }
947
+ typer.echo(json.dumps(result, indent=2))
948
+ else:
949
+ typer.echo(f"Similar to {actual_id}:")
950
+ if similar_items:
951
+ for item in similar_items:
952
+ score_str = f"({item.score:.2f})" if item.score else ""
953
+ summary_preview = item.summary[:50].replace("\n", " ")
954
+ if len(item.summary) > 50:
955
+ summary_preview += "..."
956
+ typer.echo(f" {item.id} {score_str} {summary_preview}")
957
+ else:
958
+ typer.echo(" No similar items found.")
959
+ return
960
+
774
961
  # Get specific version or current
775
962
  offset = version if version is not None else 0
776
963
 
777
964
  if offset == 0:
778
- item = kp.get(id, collection=collection)
779
- viewing_version = None
965
+ item = kp.get(actual_id, collection=collection)
966
+ internal_version = None
780
967
  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)
968
+ item = kp.get_version(actual_id, offset, collection=collection)
969
+ # Calculate internal version number for API call
970
+ versions = kp.list_versions(actual_id, limit=1, collection=collection)
784
971
  if versions:
785
- viewing_version = versions[0].version - (offset - 1)
972
+ internal_version = versions[0].version - (offset - 1)
786
973
  else:
787
- viewing_version = None
974
+ internal_version = None
788
975
 
789
976
  if item is None:
790
977
  if offset > 0:
791
- typer.echo(f"Version not found: {id} (offset {offset})", err=True)
978
+ typer.echo(f"Version not found: {actual_id} (offset {offset})", err=True)
792
979
  else:
793
- typer.echo(f"Not found: {id}", err=True)
980
+ typer.echo(f"Not found: {actual_id}", err=True)
794
981
  raise typer.Exit(1)
795
982
 
796
983
  # Get version navigation
797
- version_nav = kp.get_version_nav(id, viewing_version, collection=collection)
984
+ version_nav = kp.get_version_nav(actual_id, internal_version, collection=collection)
985
+
986
+ # Get similar items (unless suppressed or viewing old version)
987
+ similar_items = None
988
+ if not no_similar and offset == 0:
989
+ similar_items = kp.get_similar_for_display(actual_id, limit=3, collection=collection)
798
990
 
799
991
  typer.echo(_format_item(
800
992
  item,
801
993
  as_json=_get_json_output(),
802
994
  version_nav=version_nav,
803
- viewing_version=viewing_version,
995
+ viewing_offset=offset if offset > 0 else None,
996
+ similar_items=similar_items,
804
997
  ))
805
998
 
806
999
 
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
1000
  @app.command("collections")
827
1001
  def list_collections(
828
1002
  store: StoreOption = None,
@@ -910,45 +1084,6 @@ def config(
910
1084
  typer.echo(f" Summarization: {cfg.summarization.name}")
911
1085
 
912
1086
 
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
1087
  @app.command("process-pending")
953
1088
  def process_pending(
954
1089
  store: StoreOption = None,
keep/store.py CHANGED
@@ -382,6 +382,23 @@ class ChromaStore:
382
382
  result = coll.get(ids=[id], include=[])
383
383
  return bool(result["ids"])
384
384
 
385
+ def get_embedding(self, collection: str, id: str) -> list[float] | None:
386
+ """
387
+ Retrieve the stored embedding for a document.
388
+
389
+ Args:
390
+ collection: Collection name
391
+ id: Item identifier
392
+
393
+ Returns:
394
+ Embedding vector if found, None otherwise
395
+ """
396
+ coll = self._get_collection(collection)
397
+ result = coll.get(ids=[id], include=["embeddings"])
398
+ if not result["ids"] or result["embeddings"] is None or len(result["embeddings"]) == 0:
399
+ return None
400
+ return list(result["embeddings"][0])
401
+
385
402
  def list_ids(self, collection: str) -> list[str]:
386
403
  """
387
404
  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.1
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
@@ -120,6 +120,7 @@ keep find "auth" --since P7D # Last 7 days
120
120
  # Retrieve
121
121
  keep get file:///path/to/doc.md
122
122
  keep get ID -V 1 # Previous version
123
+ keep get "ID@V{1}" # Same as -V 1 (version identifier)
123
124
  keep get ID --history # All versions
124
125
 
125
126
  # Tags
@@ -1,8 +1,8 @@
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=EaPkI9Gv1r5LM8ZibqquGP_59xf4kQlCdv7nfct8QZw,58894
4
4
  keep/chunking.py,sha256=neAXOLSvVwbUxapbqq7nZrbSNSzMXuhxj-ODoOSodsU,11830
5
- keep/cli.py,sha256=yxO9FS0N1c_ffE02NV8tC-4NNj8ZNK_dkFufjqVG84A,34085
5
+ keep/cli.py,sha256=3flcfZHjvnHY8TxT-HJBBAuKnDusPAUD3ZwuMYYU7sw,40470
6
6
  keep/config.py,sha256=RRnHHvhc9KkJBYt0rpAFIvAVXw40b56xtT74TFIBiDU,15832
7
7
  keep/context.py,sha256=CNpjmrv6eW2kV1E0MO6qAQfhYKRlfzAL--6v4Mj1nFY,71
8
8
  keep/document_store.py,sha256=UswqKIGSc5E-r7Tg9k0g5-byYnuar3e9FieQ7WNod9k,29109
@@ -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=d_exdEBQM7agpJcXL-nGsl46zXPQ2t35C8QF-NQX_Bw,18097
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.1.dist-info/METADATA,sha256=sasoFYgfL9FK4nuIK99pruVoiUH65I-SN30giqc2wHs,6387
25
+ keep_skill-0.4.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
26
+ keep_skill-0.4.1.dist-info/entry_points.txt,sha256=W8yiI4kNeW0IC8ji4EHRWrvdhFxzaqTIePUhJAJAMOo,39
27
+ keep_skill-0.4.1.dist-info/licenses/LICENSE,sha256=zsm0tpvtyUkevcjn5BIvs9jAho8iwxq3Ax9647AaOSg,1086
28
+ keep_skill-0.4.1.dist-info/RECORD,,