keep-skill 0.2.0__py3-none-any.whl → 0.3.0__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
@@ -16,7 +16,8 @@ from typing import Optional
16
16
  import typer
17
17
  from typing_extensions import Annotated
18
18
 
19
- from .api import Keeper
19
+ from .api import Keeper, _text_content_id
20
+ from .document_store import VersionInfo
20
21
  from .types import Item
21
22
  from .logging_config import configure_quiet_mode, enable_debug_mode
22
23
 
@@ -65,15 +66,53 @@ app = typer.Typer(
65
66
  )
66
67
 
67
68
 
68
- def _format_yaml_frontmatter(item: Item) -> str:
69
- """Format item as YAML frontmatter with summary as content."""
69
+ def _format_yaml_frontmatter(
70
+ item: Item,
71
+ version_nav: Optional[dict[str, list[VersionInfo]]] = None,
72
+ viewing_version: Optional[int] = None,
73
+ ) -> str:
74
+ """
75
+ Format item as YAML frontmatter with summary as content.
76
+
77
+ Args:
78
+ item: The item to format
79
+ version_nav: Optional version navigation info (prev/next lists)
80
+ viewing_version: If viewing an old version, the version number
81
+ """
70
82
  lines = ["---", f"id: {item.id}"]
83
+ if viewing_version is not None:
84
+ lines.append(f"version: {viewing_version}")
71
85
  if item.tags:
72
86
  lines.append("tags:")
73
87
  for k, v in sorted(item.tags.items()):
74
88
  lines.append(f" {k}: {v}")
75
89
  if item.score is not None:
76
90
  lines.append(f"score: {item.score:.3f}")
91
+
92
+ # Add version navigation if available
93
+ if version_nav:
94
+ if version_nav.get("prev"):
95
+ 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"
99
+ summary_preview = v.summary[:40].replace("\n", " ")
100
+ if len(v.summary) > 40:
101
+ summary_preview += "..."
102
+ lines.append(f" - {v.version}: {date_part} {summary_preview}")
103
+ if version_nav.get("next"):
104
+ lines.append("next:")
105
+ for v in version_nav["next"]:
106
+ date_part = v.created_at[:10] if v.created_at else "unknown"
107
+ summary_preview = v.summary[:40].replace("\n", " ")
108
+ if len(v.summary) > 40:
109
+ summary_preview += "..."
110
+ lines.append(f" - {v.version}: {date_part} {summary_preview}")
111
+ elif viewing_version is not None:
112
+ # Viewing old version and next is empty means current is next
113
+ lines.append("next:")
114
+ lines.append(" - current")
115
+
77
116
  lines.append("---")
78
117
  lines.append(item.summary) # Summary IS the content
79
118
  return "\n".join(lines)
@@ -154,7 +193,12 @@ SinceOption = Annotated[
154
193
  # Output Helpers
155
194
  # -----------------------------------------------------------------------------
156
195
 
157
- def _format_item(item: Item, as_json: bool = False) -> str:
196
+ def _format_item(
197
+ item: Item,
198
+ as_json: bool = False,
199
+ version_nav: Optional[dict[str, list[VersionInfo]]] = None,
200
+ viewing_version: Optional[int] = None,
201
+ ) -> str:
158
202
  """
159
203
  Format an item for display.
160
204
 
@@ -165,14 +209,23 @@ def _format_item(item: Item, as_json: bool = False) -> str:
165
209
  return json.dumps(item.id) if as_json else item.id
166
210
 
167
211
  if as_json:
168
- return json.dumps({
212
+ result = {
169
213
  "id": item.id,
170
214
  "summary": item.summary,
171
215
  "tags": item.tags,
172
216
  "score": item.score,
173
- })
217
+ }
218
+ if viewing_version is not None:
219
+ result["version"] = viewing_version
220
+ 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
+ }
226
+ return json.dumps(result)
174
227
 
175
- return _format_yaml_frontmatter(item)
228
+ return _format_yaml_frontmatter(item, version_nav, viewing_version)
176
229
 
177
230
 
178
231
  def _format_items(items: list[Item], as_json: bool = False) -> str:
@@ -299,6 +352,25 @@ def search(
299
352
  typer.echo(_format_items(results, as_json=_get_json_output()))
300
353
 
301
354
 
355
+ @app.command("list")
356
+ def list_recent(
357
+ store: StoreOption = None,
358
+ collection: CollectionOption = "default",
359
+ limit: Annotated[int, typer.Option(
360
+ "--limit", "-n",
361
+ help="Number of items to show"
362
+ )] = 10,
363
+ ):
364
+ """
365
+ List recent items by update time.
366
+
367
+ Shows the most recently updated items, newest first.
368
+ """
369
+ kp = _get_keeper(store, collection)
370
+ results = kp.list_recent(limit=limit)
371
+ typer.echo(_format_items(results, as_json=_get_json_output()))
372
+
373
+
302
374
  @app.command()
303
375
  def tag(
304
376
  query: Annotated[Optional[str], typer.Argument(
@@ -446,9 +518,14 @@ def update(
446
518
 
447
519
  Three input modes (auto-detected):
448
520
  keep update file:///path # URI mode: has ://
449
- keep update "my note" # Text mode: no ://
521
+ keep update "my note" # Text mode: content-addressed ID
450
522
  keep update - # Stdin mode: explicit -
451
523
  echo "pipe" | keep update # Stdin mode: piped input
524
+
525
+ Text mode uses content-addressed IDs for versioning:
526
+ keep update "my note" # Creates _text:{hash}
527
+ keep update "my note" -t done # Same ID, new version (tag change)
528
+ keep update "different note" # Different ID (new doc)
452
529
  """
453
530
  kp = _get_keeper(store, collection)
454
531
  parsed_tags = _parse_tags(tags)
@@ -459,14 +536,16 @@ def update(
459
536
  content = sys.stdin.read()
460
537
  content, frontmatter_tags = _parse_frontmatter(content)
461
538
  parsed_tags = {**frontmatter_tags, **parsed_tags} # CLI tags override
462
- doc_id = id or f"mem:{_timestamp()}"
539
+ # Use content-addressed ID for stdin text (enables versioning)
540
+ doc_id = id or _text_content_id(content)
463
541
  item = kp.remember(content, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
464
542
  elif source and "://" in source:
465
543
  # URI mode: fetch from URI (ID is the URI itself)
466
544
  item = kp.update(source, tags=parsed_tags or None, summary=summary, lazy=lazy)
467
545
  elif source:
468
546
  # Text mode: inline content (no :// in source)
469
- doc_id = id or f"mem:{_timestamp()}"
547
+ # Use content-addressed ID for text (enables versioning)
548
+ doc_id = id or _text_content_id(source)
470
549
  item = kp.remember(source, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
471
550
  else:
472
551
  typer.echo("Error: Provide content, URI, or '-' for stdin", err=True)
@@ -488,6 +567,14 @@ def now(
488
567
  "--reset",
489
568
  help="Reset to default from system"
490
569
  )] = False,
570
+ version: Annotated[Optional[int], typer.Option(
571
+ "--version", "-V",
572
+ help="Get specific version (0=current, 1=previous, etc.)"
573
+ )] = None,
574
+ history: Annotated[bool, typer.Option(
575
+ "--history", "-H",
576
+ help="List all versions"
577
+ )] = False,
491
578
  store: StoreOption = None,
492
579
  collection: CollectionOption = "default",
493
580
  tags: Annotated[Optional[list[str]], typer.Option(
@@ -503,19 +590,87 @@ def now(
503
590
 
504
591
  Examples:
505
592
  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
593
+ keep now "What's important now" # Update context
594
+ keep now -f context.md # Read content from file
595
+ keep now --reset # Reset to default from system
596
+ keep now -V 1 # Previous version
597
+ keep now --history # List all versions
509
598
  """
599
+ from .api import NOWDOC_ID
600
+
510
601
  kp = _get_keeper(store, collection)
511
602
 
603
+ # Handle history listing
604
+ if history:
605
+ versions = kp.list_versions(NOWDOC_ID, limit=50, collection=collection)
606
+ current = kp.get(NOWDOC_ID, collection=collection)
607
+
608
+ if _get_json_output():
609
+ result = {
610
+ "id": NOWDOC_ID,
611
+ "current": {
612
+ "summary": current.summary if current else None,
613
+ } if current else None,
614
+ "versions": [
615
+ {
616
+ "version": v.version,
617
+ "summary": v.summary[:60],
618
+ "created_at": v.created_at,
619
+ }
620
+ for v in versions
621
+ ],
622
+ }
623
+ typer.echo(json.dumps(result, indent=2))
624
+ else:
625
+ if current:
626
+ summary_preview = current.summary[:60].replace("\n", " ")
627
+ typer.echo(f"Current: {summary_preview}...")
628
+ if versions:
629
+ typer.echo(f"\nVersion history ({len(versions)} archived):")
630
+ for v in versions:
631
+ date_part = v.created_at[:10] if v.created_at else "unknown"
632
+ summary_preview = v.summary[:50].replace("\n", " ")
633
+ if len(v.summary) > 50:
634
+ summary_preview += "..."
635
+ typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
636
+ else:
637
+ typer.echo("No version history.")
638
+ return
639
+
640
+ # Handle version retrieval
641
+ if version is not None:
642
+ offset = version
643
+ if offset == 0:
644
+ item = kp.get_now()
645
+ viewing_version = None
646
+ else:
647
+ item = kp.get_version(NOWDOC_ID, offset, collection=collection)
648
+ versions = kp.list_versions(NOWDOC_ID, limit=1, collection=collection)
649
+ if versions:
650
+ viewing_version = versions[0].version - (offset - 1)
651
+ else:
652
+ viewing_version = None
653
+
654
+ if item is None:
655
+ typer.echo(f"Version not found (offset {offset})", err=True)
656
+ raise typer.Exit(1)
657
+
658
+ version_nav = kp.get_version_nav(NOWDOC_ID, viewing_version, collection=collection)
659
+ typer.echo(_format_item(
660
+ item,
661
+ as_json=_get_json_output(),
662
+ version_nav=version_nav,
663
+ viewing_version=viewing_version,
664
+ ))
665
+ return
666
+
512
667
  # Determine if we're getting or setting
513
668
  setting = content is not None or file is not None or reset
514
669
 
515
670
  if setting:
516
671
  if reset:
517
672
  # Reset to default from system (delete first to clear old tags)
518
- from .api import _load_frontmatter, NOWDOC_ID, SYSTEM_DOC_DIR
673
+ from .api import _load_frontmatter, SYSTEM_DOC_DIR
519
674
  kp.delete(NOWDOC_ID)
520
675
  try:
521
676
  new_content, default_tags = _load_frontmatter(SYSTEM_DOC_DIR / "now.md")
@@ -545,28 +700,108 @@ def now(
545
700
  item = kp.set_now(new_content, tags=parsed_tags or None)
546
701
  typer.echo(_format_item(item, as_json=_get_json_output()))
547
702
  else:
548
- # Get current context
703
+ # Get current context with version navigation
549
704
  item = kp.get_now()
550
- typer.echo(_format_item(item, as_json=_get_json_output()))
705
+ version_nav = kp.get_version_nav(NOWDOC_ID, None, collection=collection)
706
+ typer.echo(_format_item(
707
+ item,
708
+ as_json=_get_json_output(),
709
+ version_nav=version_nav,
710
+ ))
551
711
 
552
712
 
553
713
  @app.command()
554
714
  def get(
555
715
  id: Annotated[str, typer.Argument(help="URI of item to retrieve")],
716
+ version: Annotated[Optional[int], typer.Option(
717
+ "--version", "-V",
718
+ help="Get specific version (0=current, 1=previous, etc.)"
719
+ )] = None,
720
+ history: Annotated[bool, typer.Option(
721
+ "--history", "-H",
722
+ help="List all versions"
723
+ )] = False,
556
724
  store: StoreOption = None,
557
725
  collection: CollectionOption = "default",
558
726
  ):
559
727
  """
560
728
  Retrieve a specific item by ID.
729
+
730
+ Examples:
731
+ keep get doc:1 # Current version with prev nav
732
+ keep get doc:1 -V 1 # Previous version with prev/next nav
733
+ keep get doc:1 --history # List all versions
561
734
  """
562
735
  kp = _get_keeper(store, collection)
563
- item = kp.get(id)
736
+
737
+ if history:
738
+ # List all versions
739
+ versions = kp.list_versions(id, limit=50, collection=collection)
740
+ current = kp.get(id, collection=collection)
741
+
742
+ if _get_json_output():
743
+ result = {
744
+ "id": id,
745
+ "current": {
746
+ "summary": current.summary if current else None,
747
+ "tags": current.tags if current else {},
748
+ } if current else None,
749
+ "versions": [
750
+ {
751
+ "version": v.version,
752
+ "summary": v.summary,
753
+ "created_at": v.created_at,
754
+ }
755
+ for v in versions
756
+ ],
757
+ }
758
+ typer.echo(json.dumps(result, indent=2))
759
+ else:
760
+ if current:
761
+ typer.echo(f"Current: {current.summary[:60]}...")
762
+ if versions:
763
+ typer.echo(f"\nVersion history ({len(versions)} archived):")
764
+ for v in versions:
765
+ date_part = v.created_at[:10] if v.created_at else "unknown"
766
+ summary_preview = v.summary[:50].replace("\n", " ")
767
+ if len(v.summary) > 50:
768
+ summary_preview += "..."
769
+ typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
770
+ else:
771
+ typer.echo("No version history.")
772
+ return
773
+
774
+ # Get specific version or current
775
+ offset = version if version is not None else 0
776
+
777
+ if offset == 0:
778
+ item = kp.get(id, collection=collection)
779
+ viewing_version = None
780
+ 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)
784
+ if versions:
785
+ viewing_version = versions[0].version - (offset - 1)
786
+ else:
787
+ viewing_version = None
564
788
 
565
789
  if item is None:
566
- typer.echo(f"Not found: {id}", err=True)
790
+ if offset > 0:
791
+ typer.echo(f"Version not found: {id} (offset {offset})", err=True)
792
+ else:
793
+ typer.echo(f"Not found: {id}", err=True)
567
794
  raise typer.Exit(1)
568
795
 
569
- typer.echo(_format_item(item, as_json=_get_json_output()))
796
+ # Get version navigation
797
+ version_nav = kp.get_version_nav(id, viewing_version, collection=collection)
798
+
799
+ typer.echo(_format_item(
800
+ item,
801
+ as_json=_get_json_output(),
802
+ version_nav=version_nav,
803
+ viewing_version=viewing_version,
804
+ ))
570
805
 
571
806
 
572
807
  @app.command()
keep/config.py CHANGED
@@ -21,7 +21,7 @@ except ImportError:
21
21
 
22
22
 
23
23
  CONFIG_FILENAME = "keep.toml"
24
- CONFIG_VERSION = 2 # Bumped for embedding identity tracking
24
+ CONFIG_VERSION = 3 # Bumped for document versioning support
25
25
 
26
26
 
27
27
  @dataclass
@@ -89,7 +89,7 @@ class StoreConfig:
89
89
  default_tags: dict[str, str] = field(default_factory=dict)
90
90
 
91
91
  # Maximum length for summaries (used for smart remember and validation)
92
- max_summary_length: int = 1000
92
+ max_summary_length: int = 500
93
93
 
94
94
  @property
95
95
  def config_path(self) -> Path: