keep-skill 0.2.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/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,7 +17,12 @@ from typing import Optional
16
17
  import typer
17
18
  from typing_extensions import Annotated
18
19
 
19
- from .api import Keeper
20
+
21
+ # Pattern for version identifier suffix: @V{N} where N is digits only
22
+ VERSION_SUFFIX_PATTERN = re.compile(r'@V\{(\d+)\}$')
23
+
24
+ from .api import Keeper, _text_content_id
25
+ from .document_store import VersionInfo
20
26
  from .types import Item
21
27
  from .logging_config import configure_quiet_mode, enable_debug_mode
22
28
 
@@ -37,6 +43,7 @@ def _verbose_callback(value: bool):
37
43
  # Global state for CLI options
38
44
  _json_output = False
39
45
  _ids_output = False
46
+ _full_output = False
40
47
 
41
48
 
42
49
  def _json_callback(value: bool):
@@ -57,23 +64,89 @@ def _get_ids_output() -> bool:
57
64
  return _ids_output
58
65
 
59
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
+
60
76
  app = typer.Typer(
61
77
  name="keep",
62
78
  help="Associative memory with semantic search.",
63
79
  no_args_is_help=False,
64
80
  invoke_without_command=True,
81
+ rich_markup_mode=None,
65
82
  )
66
83
 
67
84
 
68
- def _format_yaml_frontmatter(item: Item) -> str:
69
- """Format item as YAML frontmatter with summary as content."""
85
+ def _format_yaml_frontmatter(
86
+ item: Item,
87
+ version_nav: Optional[dict[str, list[VersionInfo]]] = None,
88
+ viewing_offset: Optional[int] = None,
89
+ similar_items: Optional[list[Item]] = None,
90
+ ) -> str:
91
+ """
92
+ Format item as YAML frontmatter with summary as content.
93
+
94
+ Args:
95
+ item: The item to format
96
+ version_nav: Optional version navigation info (prev/next lists)
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.
103
+ """
70
104
  lines = ["---", f"id: {item.id}"]
105
+ if viewing_offset is not None:
106
+ lines.append(f"version: {viewing_offset}")
71
107
  if item.tags:
72
- lines.append("tags:")
73
- for k, v in sorted(item.tags.items()):
74
- 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}}}")
75
110
  if item.score is not None:
76
111
  lines.append(f"score: {item.score:.3f}")
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
+
120
+ # Add version navigation if available
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
+
125
+ if version_nav.get("prev"):
126
+ lines.append("prev:")
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
130
+ date_part = v.created_at[:10] if v.created_at else "unknown"
131
+ summary_preview = v.summary[:40].replace("\n", " ")
132
+ if len(v.summary) > 40:
133
+ summary_preview += "..."
134
+ lines.append(f" - v{prev_offset}: {date_part} {summary_preview}")
135
+ if version_nav.get("next"):
136
+ lines.append("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
140
+ date_part = v.created_at[:10] if v.created_at else "unknown"
141
+ summary_preview = v.summary[:40].replace("\n", " ")
142
+ if len(v.summary) > 40:
143
+ summary_preview += "..."
144
+ lines.append(f" - v{next_offset}: {date_part} {summary_preview}")
145
+ elif viewing_offset is not None:
146
+ # Viewing old version and next is empty means current is next
147
+ lines.append("next:")
148
+ lines.append(" - v0 (current)")
149
+
77
150
  lines.append("---")
78
151
  lines.append(item.summary) # Summary IS the content
79
152
  return "\n".join(lines)
@@ -100,15 +173,27 @@ def main_callback(
100
173
  callback=_ids_callback,
101
174
  is_eager=True,
102
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,
103
182
  ):
104
183
  """Associative memory with semantic search."""
105
184
  # If no subcommand provided, show the current context (now)
106
185
  if ctx.invoked_subcommand is None:
186
+ from .api import NOWDOC_ID
107
187
  kp = _get_keeper(None, "default")
108
188
  item = kp.get_now()
109
- typer.echo(_format_item(item, as_json=_get_json_output()))
110
- if not _get_json_output():
111
- 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
+ ))
112
197
 
113
198
 
114
199
  # -----------------------------------------------------------------------------
@@ -154,25 +239,72 @@ SinceOption = Annotated[
154
239
  # Output Helpers
155
240
  # -----------------------------------------------------------------------------
156
241
 
157
- def _format_item(item: Item, as_json: bool = False) -> str:
242
+ def _format_item(
243
+ item: Item,
244
+ as_json: bool = False,
245
+ version_nav: Optional[dict[str, list[VersionInfo]]] = None,
246
+ viewing_offset: Optional[int] = None,
247
+ similar_items: Optional[list[Item]] = None,
248
+ ) -> str:
158
249
  """
159
250
  Format an item for display.
160
251
 
161
252
  Text format: YAML frontmatter (matches docs/system format)
162
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
163
261
  """
164
262
  if _get_ids_output():
165
263
  return json.dumps(item.id) if as_json else item.id
166
264
 
167
265
  if as_json:
168
- return json.dumps({
266
+ result = {
169
267
  "id": item.id,
170
268
  "summary": item.summary,
171
269
  "tags": item.tags,
172
270
  "score": item.score,
173
- })
174
-
175
- return _format_yaml_frontmatter(item)
271
+ }
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
+ ]
280
+ if version_nav:
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"}]
305
+ return json.dumps(result)
306
+
307
+ return _format_yaml_frontmatter(item, version_nav, viewing_offset, similar_items)
176
308
 
177
309
 
178
310
  def _format_items(items: list[Item], as_json: bool = False) -> str:
@@ -299,6 +431,36 @@ def search(
299
431
  typer.echo(_format_items(results, as_json=_get_json_output()))
300
432
 
301
433
 
434
+ @app.command("list")
435
+ def list_recent(
436
+ store: StoreOption = None,
437
+ collection: CollectionOption = "default",
438
+ limit: Annotated[int, typer.Option(
439
+ "--limit", "-n",
440
+ help="Number of items to show"
441
+ )] = 10,
442
+ ):
443
+ """
444
+ List recent items by update time.
445
+
446
+ Shows IDs by default (composable). Use --full for detailed output.
447
+ """
448
+ kp = _get_keeper(store, collection)
449
+ results = kp.list_recent(limit=limit)
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)
462
+
463
+
302
464
  @app.command()
303
465
  def tag(
304
466
  query: Annotated[Optional[str], typer.Argument(
@@ -446,9 +608,14 @@ def update(
446
608
 
447
609
  Three input modes (auto-detected):
448
610
  keep update file:///path # URI mode: has ://
449
- keep update "my note" # Text mode: no ://
611
+ keep update "my note" # Text mode: content-addressed ID
450
612
  keep update - # Stdin mode: explicit -
451
613
  echo "pipe" | keep update # Stdin mode: piped input
614
+
615
+ Text mode uses content-addressed IDs for versioning:
616
+ keep update "my note" # Creates _text:{hash}
617
+ keep update "my note" -t done # Same ID, new version (tag change)
618
+ keep update "different note" # Different ID (new doc)
452
619
  """
453
620
  kp = _get_keeper(store, collection)
454
621
  parsed_tags = _parse_tags(tags)
@@ -459,14 +626,16 @@ def update(
459
626
  content = sys.stdin.read()
460
627
  content, frontmatter_tags = _parse_frontmatter(content)
461
628
  parsed_tags = {**frontmatter_tags, **parsed_tags} # CLI tags override
462
- doc_id = id or f"mem:{_timestamp()}"
629
+ # Use content-addressed ID for stdin text (enables versioning)
630
+ doc_id = id or _text_content_id(content)
463
631
  item = kp.remember(content, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
464
632
  elif source and "://" in source:
465
633
  # URI mode: fetch from URI (ID is the URI itself)
466
634
  item = kp.update(source, tags=parsed_tags or None, summary=summary, lazy=lazy)
467
635
  elif source:
468
636
  # Text mode: inline content (no :// in source)
469
- doc_id = id or f"mem:{_timestamp()}"
637
+ # Use content-addressed ID for text (enables versioning)
638
+ doc_id = id or _text_content_id(source)
470
639
  item = kp.remember(source, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
471
640
  else:
472
641
  typer.echo("Error: Provide content, URI, or '-' for stdin", err=True)
@@ -488,6 +657,14 @@ def now(
488
657
  "--reset",
489
658
  help="Reset to default from system"
490
659
  )] = False,
660
+ version: Annotated[Optional[int], typer.Option(
661
+ "--version", "-V",
662
+ help="Get specific version (0=current, 1=previous, etc.)"
663
+ )] = None,
664
+ history: Annotated[bool, typer.Option(
665
+ "--history", "-H",
666
+ help="List all versions"
667
+ )] = False,
491
668
  store: StoreOption = None,
492
669
  collection: CollectionOption = "default",
493
670
  tags: Annotated[Optional[list[str]], typer.Option(
@@ -503,19 +680,100 @@ def now(
503
680
 
504
681
  Examples:
505
682
  keep now # Show current context
506
- keep now "Working on auth flow" # Set context
507
- keep now -f context.md # Set from file
508
- keep now --reset # Reset to default
683
+ keep now "What's important now" # Update context
684
+ keep now -f context.md # Read content from file
685
+ keep now --reset # Reset to default from system
686
+ keep now -V 1 # Previous version
687
+ keep now --history # List all versions
509
688
  """
689
+ from .api import NOWDOC_ID
690
+
510
691
  kp = _get_keeper(store, collection)
511
692
 
693
+ # Handle history listing
694
+ if history:
695
+ versions = kp.list_versions(NOWDOC_ID, limit=50, collection=collection)
696
+ current = kp.get(NOWDOC_ID, collection=collection)
697
+
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():
705
+ result = {
706
+ "id": NOWDOC_ID,
707
+ "current": {
708
+ "summary": current.summary if current else None,
709
+ "offset": 0,
710
+ "vid": f"{NOWDOC_ID}@V{{0}}",
711
+ } if current else None,
712
+ "versions": [
713
+ {
714
+ "offset": i + 1,
715
+ "vid": f"{NOWDOC_ID}@V{{{i + 1}}}",
716
+ "version": v.version,
717
+ "summary": v.summary[:60],
718
+ "created_at": v.created_at,
719
+ }
720
+ for i, v in enumerate(versions)
721
+ ],
722
+ }
723
+ typer.echo(json.dumps(result, indent=2))
724
+ else:
725
+ if current:
726
+ summary_preview = current.summary[:60].replace("\n", " ")
727
+ if len(current.summary) > 60:
728
+ summary_preview += "..."
729
+ typer.echo(f"v0 (current): {summary_preview}")
730
+ if versions:
731
+ typer.echo(f"\nArchived:")
732
+ for i, v in enumerate(versions, start=1):
733
+ date_part = v.created_at[:10] if v.created_at else "unknown"
734
+ summary_preview = v.summary[:50].replace("\n", " ")
735
+ if len(v.summary) > 50:
736
+ summary_preview += "..."
737
+ typer.echo(f" v{i} ({date_part}): {summary_preview}")
738
+ else:
739
+ typer.echo("No version history.")
740
+ return
741
+
742
+ # Handle version retrieval
743
+ if version is not None:
744
+ offset = version
745
+ if offset == 0:
746
+ item = kp.get_now()
747
+ internal_version = None
748
+ else:
749
+ item = kp.get_version(NOWDOC_ID, offset, collection=collection)
750
+ # Get internal version number for API call
751
+ versions = kp.list_versions(NOWDOC_ID, limit=1, collection=collection)
752
+ if versions:
753
+ internal_version = versions[0].version - (offset - 1)
754
+ else:
755
+ internal_version = None
756
+
757
+ if item is None:
758
+ typer.echo(f"Version not found (offset {offset})", err=True)
759
+ raise typer.Exit(1)
760
+
761
+ version_nav = kp.get_version_nav(NOWDOC_ID, internal_version, collection=collection)
762
+ typer.echo(_format_item(
763
+ item,
764
+ as_json=_get_json_output(),
765
+ version_nav=version_nav,
766
+ viewing_offset=offset if offset > 0 else None,
767
+ ))
768
+ return
769
+
512
770
  # Determine if we're getting or setting
513
771
  setting = content is not None or file is not None or reset
514
772
 
515
773
  if setting:
516
774
  if reset:
517
775
  # Reset to default from system (delete first to clear old tags)
518
- from .api import _load_frontmatter, NOWDOC_ID, SYSTEM_DOC_DIR
776
+ from .api import _load_frontmatter, SYSTEM_DOC_DIR
519
777
  kp.delete(NOWDOC_ID)
520
778
  try:
521
779
  new_content, default_tags = _load_frontmatter(SYSTEM_DOC_DIR / "now.md")
@@ -545,48 +803,199 @@ def now(
545
803
  item = kp.set_now(new_content, tags=parsed_tags or None)
546
804
  typer.echo(_format_item(item, as_json=_get_json_output()))
547
805
  else:
548
- # Get current context
806
+ # Get current context with version navigation and similar items
549
807
  item = kp.get_now()
550
- typer.echo(_format_item(item, as_json=_get_json_output()))
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)
810
+ typer.echo(_format_item(
811
+ item,
812
+ as_json=_get_json_output(),
813
+ version_nav=version_nav,
814
+ similar_items=similar_items,
815
+ ))
551
816
 
552
817
 
553
818
  @app.command()
554
819
  def get(
555
- 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)")],
821
+ version: Annotated[Optional[int], typer.Option(
822
+ "--version", "-V",
823
+ help="Get specific version (0=current, 1=previous, etc.)"
824
+ )] = None,
825
+ history: Annotated[bool, typer.Option(
826
+ "--history", "-H",
827
+ help="List all versions"
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,
556
841
  store: StoreOption = None,
557
842
  collection: CollectionOption = "default",
558
843
  ):
559
844
  """
560
845
  Retrieve a specific item by ID.
846
+
847
+ Version identifiers: Append @V{N} to get a specific version.
848
+
849
+ Examples:
850
+ keep get doc:1 # Current version with similar items
851
+ keep get doc:1 -V 1 # Previous version with prev/next nav
852
+ keep get "doc:1@V{1}" # Same as -V 1
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
561
856
  """
562
857
  kp = _get_keeper(store, collection)
563
- item = kp.get(id)
564
858
 
565
- if item is None:
566
- typer.echo(f"Not found: {id}", err=True)
567
- raise typer.Exit(1)
859
+ # Parse @V{N} version identifier from ID (security: check literal first)
860
+ actual_id = id
861
+ version_from_id = None
568
862
 
569
- typer.echo(_format_item(item, as_json=_get_json_output()))
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
+
877
+ if history:
878
+ # List all versions
879
+ versions = kp.list_versions(actual_id, limit=limit, collection=collection)
880
+ current = kp.get(actual_id, collection=collection)
881
+
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():
889
+ result = {
890
+ "id": actual_id,
891
+ "current": {
892
+ "summary": current.summary if current else None,
893
+ "tags": current.tags if current else {},
894
+ "offset": 0,
895
+ "vid": f"{actual_id}@V{{0}}",
896
+ } if current else None,
897
+ "versions": [
898
+ {
899
+ "offset": i + 1,
900
+ "vid": f"{actual_id}@V{{{i + 1}}}",
901
+ "version": v.version,
902
+ "summary": v.summary,
903
+ "created_at": v.created_at,
904
+ }
905
+ for i, v in enumerate(versions)
906
+ ],
907
+ }
908
+ typer.echo(json.dumps(result, indent=2))
909
+ else:
910
+ if current:
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}")
915
+ if versions:
916
+ typer.echo(f"\nArchived:")
917
+ for i, v in enumerate(versions, start=1):
918
+ date_part = v.created_at[:10] if v.created_at else "unknown"
919
+ summary_preview = v.summary[:50].replace("\n", " ")
920
+ if len(v.summary) > 50:
921
+ summary_preview += "..."
922
+ typer.echo(f" v{i} ({date_part}): {summary_preview}")
923
+ else:
924
+ typer.echo("No version history.")
925
+ return
570
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
571
960
 
572
- @app.command()
573
- def exists(
574
- id: Annotated[str, typer.Argument(help="URI to check")],
575
- store: StoreOption = None,
576
- collection: CollectionOption = "default",
577
- ):
578
- """
579
- Check if an item exists in the store.
580
- """
581
- kp = _get_keeper(store, collection)
582
- found = kp.exists(id)
583
-
584
- if found:
585
- typer.echo(f"Exists: {id}")
961
+ # Get specific version or current
962
+ offset = version if version is not None else 0
963
+
964
+ if offset == 0:
965
+ item = kp.get(actual_id, collection=collection)
966
+ internal_version = None
586
967
  else:
587
- typer.echo(f"Not found: {id}")
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)
971
+ if versions:
972
+ internal_version = versions[0].version - (offset - 1)
973
+ else:
974
+ internal_version = None
975
+
976
+ if item is None:
977
+ if offset > 0:
978
+ typer.echo(f"Version not found: {actual_id} (offset {offset})", err=True)
979
+ else:
980
+ typer.echo(f"Not found: {actual_id}", err=True)
588
981
  raise typer.Exit(1)
589
982
 
983
+ # Get version navigation
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)
990
+
991
+ typer.echo(_format_item(
992
+ item,
993
+ as_json=_get_json_output(),
994
+ version_nav=version_nav,
995
+ viewing_offset=offset if offset > 0 else None,
996
+ similar_items=similar_items,
997
+ ))
998
+
590
999
 
591
1000
  @app.command("collections")
592
1001
  def list_collections(
@@ -675,45 +1084,6 @@ def config(
675
1084
  typer.echo(f" Summarization: {cfg.summarization.name}")
676
1085
 
677
1086
 
678
- @app.command("system")
679
- def list_system(
680
- store: StoreOption = None,
681
- ):
682
- """
683
- List the system documents.
684
-
685
- Shows ID and summary for each. Use `keep get ID` for full details.
686
- """
687
- kp = _get_keeper(store, "default")
688
- docs = kp.list_system_documents()
689
-
690
- # Use --ids flag for pipe-friendly output
691
- if _get_ids_output():
692
- ids = [doc.id for doc in docs]
693
- if _get_json_output():
694
- typer.echo(json.dumps(ids))
695
- else:
696
- for doc_id in ids:
697
- typer.echo(doc_id)
698
- return
699
-
700
- if _get_json_output():
701
- typer.echo(json.dumps([
702
- {"id": doc.id, "summary": doc.summary}
703
- for doc in docs
704
- ], indent=2))
705
- else:
706
- if not docs:
707
- typer.echo("No system documents.")
708
- else:
709
- for doc in docs:
710
- # Compact summary: collapse whitespace, truncate to 70 chars
711
- summary = " ".join(doc.summary.split())[:70]
712
- if len(doc.summary) > 70:
713
- summary += "..."
714
- typer.echo(f"{doc.id}: {summary}")
715
-
716
-
717
1087
  @app.command("process-pending")
718
1088
  def process_pending(
719
1089
  store: StoreOption = None,