keep-skill 0.4.1__py3-none-any.whl → 0.6.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
@@ -17,10 +17,13 @@ from typing import Optional
17
17
  import typer
18
18
  from typing_extensions import Annotated
19
19
 
20
-
21
20
  # Pattern for version identifier suffix: @V{N} where N is digits only
22
21
  VERSION_SUFFIX_PATTERN = re.compile(r'@V\{(\d+)\}$')
23
22
 
23
+ # URI scheme pattern per RFC 3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
24
+ # Used to distinguish URIs from plain text in the update command
25
+ _URI_SCHEME_PATTERN = re.compile(r'^[a-zA-Z][a-zA-Z0-9+.-]*://')
26
+
24
27
  from .api import Keeper, _text_content_id
25
28
  from .document_store import VersionInfo
26
29
  from .types import Item
@@ -82,11 +85,29 @@ app = typer.Typer(
82
85
  )
83
86
 
84
87
 
88
+ # -----------------------------------------------------------------------------
89
+ # Output Formatting
90
+ #
91
+ # Three output formats, controlled by global flags:
92
+ # --ids: versioned ID only (id@V{N})
93
+ # --full: YAML frontmatter with tags, similar items, version nav
94
+ # default: summary line (id@V{N} date summary)
95
+ #
96
+ # JSON output (--json) works with any of the above.
97
+ # -----------------------------------------------------------------------------
98
+
99
+ def _filter_display_tags(tags: dict) -> dict:
100
+ """Filter out internal-only tags for display."""
101
+ from .types import INTERNAL_TAGS
102
+ return {k: v for k, v in tags.items() if k not in INTERNAL_TAGS}
103
+
104
+
85
105
  def _format_yaml_frontmatter(
86
106
  item: Item,
87
107
  version_nav: Optional[dict[str, list[VersionInfo]]] = None,
88
108
  viewing_offset: Optional[int] = None,
89
109
  similar_items: Optional[list[Item]] = None,
110
+ similar_offsets: Optional[dict[str, int]] = None,
90
111
  ) -> str:
91
112
  """
92
113
  Format item as YAML frontmatter with summary as content.
@@ -96,6 +117,7 @@ def _format_yaml_frontmatter(
96
117
  version_nav: Optional version navigation info (prev/next lists)
97
118
  viewing_offset: If viewing an old version, the offset (1=previous, 2=two ago)
98
119
  similar_items: Optional list of similar items to display
120
+ similar_offsets: Version offsets for similar items (item.id -> offset)
99
121
 
100
122
  Note: Offset computation (v1, v2, etc.) assumes version_nav lists
101
123
  are ordered newest-first, matching list_versions() ordering.
@@ -104,20 +126,27 @@ def _format_yaml_frontmatter(
104
126
  lines = ["---", f"id: {item.id}"]
105
127
  if viewing_offset is not None:
106
128
  lines.append(f"version: {viewing_offset}")
107
- if item.tags:
108
- tag_items = ", ".join(f"{k}: {v}" for k, v in sorted(item.tags.items()))
129
+ display_tags = _filter_display_tags(item.tags)
130
+ if display_tags:
131
+ tag_items = ", ".join(f"{k}: {v}" for k, v in sorted(display_tags.items()))
109
132
  lines.append(f"tags: {{{tag_items}}}")
110
133
  if item.score is not None:
111
134
  lines.append(f"score: {item.score:.3f}")
112
135
 
113
- # Add similar items if available
136
+ # Add similar items if available (version-scoped IDs with date and summary)
114
137
  if similar_items:
115
138
  lines.append("similar:")
116
139
  for sim_item in similar_items:
140
+ base_id = sim_item.tags.get("_base_id", sim_item.id)
141
+ offset = (similar_offsets or {}).get(sim_item.id, 0)
117
142
  score_str = f"({sim_item.score:.2f})" if sim_item.score else ""
118
- lines.append(f" - {sim_item.id} {score_str}")
143
+ date_part = sim_item.tags.get("_updated", sim_item.tags.get("_created", ""))[:10]
144
+ summary_preview = sim_item.summary[:40].replace("\n", " ")
145
+ if len(sim_item.summary) > 40:
146
+ summary_preview += "..."
147
+ lines.append(f" - {base_id}@V{{{offset}}} {score_str} {date_part} {summary_preview}")
119
148
 
120
- # Add version navigation if available
149
+ # Add version navigation (just @V{N} since ID is shown at top, with date + summary)
121
150
  if version_nav:
122
151
  # Current offset (0 if viewing current)
123
152
  current_offset = viewing_offset if viewing_offset is not None else 0
@@ -125,33 +154,56 @@ def _format_yaml_frontmatter(
125
154
  if version_nav.get("prev"):
126
155
  lines.append("prev:")
127
156
  for i, v in enumerate(version_nav["prev"]):
128
- # Offset for this prev item: current_offset + i + 1
129
157
  prev_offset = current_offset + i + 1
130
- date_part = v.created_at[:10] if v.created_at else "unknown"
158
+ date_part = v.created_at[:10] if v.created_at else ""
131
159
  summary_preview = v.summary[:40].replace("\n", " ")
132
160
  if len(v.summary) > 40:
133
161
  summary_preview += "..."
134
- lines.append(f" - v{prev_offset}: {date_part} {summary_preview}")
162
+ lines.append(f" - @V{{{prev_offset}}} {date_part} {summary_preview}")
135
163
  if version_nav.get("next"):
136
164
  lines.append("next:")
137
165
  for i, v in enumerate(version_nav["next"]):
138
- # Offset for this next item: current_offset - i - 1
139
166
  next_offset = current_offset - i - 1
140
- date_part = v.created_at[:10] if v.created_at else "unknown"
167
+ date_part = v.created_at[:10] if v.created_at else ""
141
168
  summary_preview = v.summary[:40].replace("\n", " ")
142
169
  if len(v.summary) > 40:
143
170
  summary_preview += "..."
144
- lines.append(f" - v{next_offset}: {date_part} {summary_preview}")
171
+ lines.append(f" - @V{{{next_offset}}} {date_part} {summary_preview}")
145
172
  elif viewing_offset is not None:
146
173
  # Viewing old version and next is empty means current is next
147
174
  lines.append("next:")
148
- lines.append(" - v0 (current)")
175
+ lines.append(f" - @V{{0}}")
149
176
 
150
177
  lines.append("---")
151
178
  lines.append(item.summary) # Summary IS the content
152
179
  return "\n".join(lines)
153
180
 
154
181
 
182
+ def _format_summary_line(item: Item) -> str:
183
+ """Format item as single summary line: id@version date summary"""
184
+ # Get version-scoped ID
185
+ base_id = item.tags.get("_base_id", item.id)
186
+ version = item.tags.get("_version", "0")
187
+ versioned_id = f"{base_id}@V{{{version}}}"
188
+
189
+ # Get date (from _updated_date or _updated or _created)
190
+ date = item.tags.get("_updated_date") or item.tags.get("_updated", "")[:10] or item.tags.get("_created", "")[:10] or ""
191
+
192
+ # Truncate summary to ~60 chars, collapse newlines
193
+ summary = item.summary.replace("\n", " ")
194
+ if len(summary) > 60:
195
+ summary = summary[:57].rsplit(" ", 1)[0] + "..."
196
+
197
+ return f"{versioned_id} {date} {summary}"
198
+
199
+
200
+ def _format_versioned_id(item: Item) -> str:
201
+ """Format item ID with version suffix: id@V{N}"""
202
+ base_id = item.tags.get("_base_id", item.id)
203
+ version = item.tags.get("_version", "0")
204
+ return f"{base_id}@V{{{version}}}"
205
+
206
+
155
207
  @app.callback(invoke_without_command=True)
156
208
  def main_callback(
157
209
  ctx: typer.Context,
@@ -188,11 +240,13 @@ def main_callback(
188
240
  item = kp.get_now()
189
241
  version_nav = kp.get_version_nav(NOWDOC_ID, None, collection="default")
190
242
  similar_items = kp.get_similar_for_display(NOWDOC_ID, limit=3, collection="default")
243
+ similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
191
244
  typer.echo(_format_item(
192
245
  item,
193
246
  as_json=_get_json_output(),
194
247
  version_nav=version_nav,
195
248
  similar_items=similar_items,
249
+ similar_offsets=similar_offsets,
196
250
  ))
197
251
 
198
252
 
@@ -235,38 +289,39 @@ SinceOption = Annotated[
235
289
  ]
236
290
 
237
291
 
238
- # -----------------------------------------------------------------------------
239
- # Output Helpers
240
- # -----------------------------------------------------------------------------
241
-
242
292
  def _format_item(
243
293
  item: Item,
244
294
  as_json: bool = False,
245
295
  version_nav: Optional[dict[str, list[VersionInfo]]] = None,
246
296
  viewing_offset: Optional[int] = None,
247
297
  similar_items: Optional[list[Item]] = None,
298
+ similar_offsets: Optional[dict[str, int]] = None,
248
299
  ) -> str:
249
300
  """
250
- Format an item for display.
301
+ Format a single item for display.
251
302
 
252
- Text format: YAML frontmatter (matches docs/system format)
253
- With --ids: just the ID (for piping)
303
+ Output selection:
304
+ --ids: versioned ID only
305
+ --full or version_nav/similar_items present: YAML frontmatter
306
+ default: summary line (id@V{N} date summary)
254
307
 
255
308
  Args:
256
309
  item: The item to format
257
310
  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
311
+ version_nav: Version navigation info (triggers full format)
312
+ viewing_offset: Version offset if viewing old version (triggers full format)
313
+ similar_items: Similar items to display (triggers full format)
314
+ similar_offsets: Version offsets for similar items
261
315
  """
262
316
  if _get_ids_output():
263
- return json.dumps(item.id) if as_json else item.id
317
+ versioned_id = _format_versioned_id(item)
318
+ return json.dumps(versioned_id) if as_json else versioned_id
264
319
 
265
320
  if as_json:
266
321
  result = {
267
322
  "id": item.id,
268
323
  "summary": item.summary,
269
- "tags": item.tags,
324
+ "tags": _filter_display_tags(item.tags),
270
325
  "score": item.score,
271
326
  }
272
327
  if viewing_offset is not None:
@@ -274,7 +329,12 @@ def _format_item(
274
329
  result["vid"] = f"{item.id}@V{{{viewing_offset}}}"
275
330
  if similar_items:
276
331
  result["similar"] = [
277
- {"id": s.id, "score": s.score, "summary": s.summary[:60]}
332
+ {
333
+ "id": f"{s.tags.get('_base_id', s.id)}@V{{{(similar_offsets or {}).get(s.id, 0)}}}",
334
+ "score": s.score,
335
+ "date": s.tags.get("_updated", s.tags.get("_created", ""))[:10],
336
+ "summary": s.summary[:60],
337
+ }
278
338
  for s in similar_items
279
339
  ]
280
340
  if version_nav:
@@ -304,13 +364,18 @@ def _format_item(
304
364
  result["version_nav"]["next"] = [{"offset": 0, "vid": f"{item.id}@V{{0}}", "label": "current"}]
305
365
  return json.dumps(result)
306
366
 
307
- return _format_yaml_frontmatter(item, version_nav, viewing_offset, similar_items)
367
+ # Full format when:
368
+ # - --full flag is set
369
+ # - version navigation or similar items are provided (can't display in summary)
370
+ if _get_full_output() or version_nav or similar_items or viewing_offset is not None:
371
+ return _format_yaml_frontmatter(item, version_nav, viewing_offset, similar_items, similar_offsets)
372
+ return _format_summary_line(item)
308
373
 
309
374
 
310
375
  def _format_items(items: list[Item], as_json: bool = False) -> str:
311
376
  """Format multiple items for display."""
312
377
  if _get_ids_output():
313
- ids = [item.id for item in items]
378
+ ids = [_format_versioned_id(item) for item in items]
314
379
  return json.dumps(ids) if as_json else "\n".join(ids)
315
380
 
316
381
  if as_json:
@@ -318,15 +383,20 @@ def _format_items(items: list[Item], as_json: bool = False) -> str:
318
383
  {
319
384
  "id": item.id,
320
385
  "summary": item.summary,
321
- "tags": item.tags,
386
+ "tags": _filter_display_tags(item.tags),
322
387
  "score": item.score,
323
388
  }
324
389
  for item in items
325
390
  ], indent=2)
326
- else:
327
- if not items:
328
- return "No results."
329
- return "\n\n".join(_format_item(item, as_json=False) for item in items)
391
+
392
+ if not items:
393
+ return "No results."
394
+
395
+ # Full format: YAML frontmatter with double-newline separator
396
+ # Default: summary lines with single-newline separator
397
+ if _get_full_output():
398
+ return "\n\n".join(_format_yaml_frontmatter(item) for item in items)
399
+ return "\n".join(_format_summary_line(item) for item in items)
330
400
 
331
401
 
332
402
  def _get_keeper(store: Optional[Path], collection: str) -> Keeper:
@@ -417,7 +487,7 @@ def find(
417
487
 
418
488
  @app.command()
419
489
  def search(
420
- query: Annotated[str, typer.Argument(help="Full-text search query")],
490
+ query: Annotated[str, typer.Argument(default=..., help="Full-text search query")],
421
491
  store: StoreOption = None,
422
492
  collection: CollectionOption = "default",
423
493
  limit: LimitOption = 10,
@@ -443,22 +513,11 @@ def list_recent(
443
513
  """
444
514
  List recent items by update time.
445
515
 
446
- Shows IDs by default (composable). Use --full for detailed output.
516
+ Default: summary lines. Use --ids for IDs only, --full for YAML.
447
517
  """
448
518
  kp = _get_keeper(store, collection)
449
519
  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)
520
+ typer.echo(_format_items(results, as_json=_get_json_output()))
462
521
 
463
522
 
464
523
  @app.command()
@@ -519,7 +578,7 @@ def tag(
519
578
 
520
579
  @app.command("tag-update")
521
580
  def tag_update(
522
- ids: Annotated[list[str], typer.Argument(help="Document IDs to tag")],
581
+ ids: Annotated[list[str], typer.Argument(default=..., help="Document IDs to tag")],
523
582
  tags: Annotated[Optional[list[str]], typer.Option(
524
583
  "--tag", "-t",
525
584
  help="Tag as key=value (empty value removes: key=)"
@@ -629,7 +688,7 @@ def update(
629
688
  # Use content-addressed ID for stdin text (enables versioning)
630
689
  doc_id = id or _text_content_id(content)
631
690
  item = kp.remember(content, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
632
- elif source and "://" in source:
691
+ elif source and _URI_SCHEME_PATTERN.match(source):
633
692
  # URI mode: fetch from URI (ID is the URI itself)
634
693
  item = kp.update(source, tags=parsed_tags or None, summary=summary, lazy=lazy)
635
694
  elif source:
@@ -807,17 +866,19 @@ def now(
807
866
  item = kp.get_now()
808
867
  version_nav = kp.get_version_nav(NOWDOC_ID, None, collection=collection)
809
868
  similar_items = kp.get_similar_for_display(NOWDOC_ID, limit=3, collection=collection)
869
+ similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
810
870
  typer.echo(_format_item(
811
871
  item,
812
872
  as_json=_get_json_output(),
813
873
  version_nav=version_nav,
814
874
  similar_items=similar_items,
875
+ similar_offsets=similar_offsets,
815
876
  ))
816
877
 
817
878
 
818
879
  @app.command()
819
880
  def get(
820
- id: Annotated[str, typer.Argument(help="URI of item (append @V{N} for version)")],
881
+ id: Annotated[str, typer.Argument(default=..., help="URI of item (append @V{N} for version)")],
821
882
  version: Annotated[Optional[int], typer.Option(
822
883
  "--version", "-V",
823
884
  help="Get specific version (0=current, 1=previous, etc.)"
@@ -927,18 +988,22 @@ def get(
927
988
  if similar:
928
989
  # List similar items
929
990
  similar_items = kp.get_similar_for_display(actual_id, limit=limit, collection=collection)
991
+ similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
930
992
 
931
993
  if _get_ids_output():
932
- # Output IDs one per line
994
+ # Output version-scoped IDs one per line
933
995
  for item in similar_items:
934
- typer.echo(item.id)
996
+ base_id = item.tags.get("_base_id", item.id)
997
+ offset = similar_offsets.get(item.id, 0)
998
+ typer.echo(f"{base_id}@V{{{offset}}}")
935
999
  elif _get_json_output():
936
1000
  result = {
937
1001
  "id": actual_id,
938
1002
  "similar": [
939
1003
  {
940
- "id": item.id,
1004
+ "id": f"{item.tags.get('_base_id', item.id)}@V{{{similar_offsets.get(item.id, 0)}}}",
941
1005
  "score": item.score,
1006
+ "date": item.tags.get("_updated", item.tags.get("_created", ""))[:10],
942
1007
  "summary": item.summary[:60],
943
1008
  }
944
1009
  for item in similar_items
@@ -949,11 +1014,14 @@ def get(
949
1014
  typer.echo(f"Similar to {actual_id}:")
950
1015
  if similar_items:
951
1016
  for item in similar_items:
1017
+ base_id = item.tags.get("_base_id", item.id)
1018
+ offset = similar_offsets.get(item.id, 0)
952
1019
  score_str = f"({item.score:.2f})" if item.score else ""
1020
+ date_part = item.tags.get("_updated", item.tags.get("_created", ""))[:10]
953
1021
  summary_preview = item.summary[:50].replace("\n", " ")
954
1022
  if len(item.summary) > 50:
955
1023
  summary_preview += "..."
956
- typer.echo(f" {item.id} {score_str} {summary_preview}")
1024
+ typer.echo(f" {base_id}@V{{{offset}}} {score_str} {date_part} {summary_preview}")
957
1025
  else:
958
1026
  typer.echo(" No similar items found.")
959
1027
  return
@@ -985,8 +1053,10 @@ def get(
985
1053
 
986
1054
  # Get similar items (unless suppressed or viewing old version)
987
1055
  similar_items = None
1056
+ similar_offsets = None
988
1057
  if not no_similar and offset == 0:
989
1058
  similar_items = kp.get_similar_for_display(actual_id, limit=3, collection=collection)
1059
+ similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
990
1060
 
991
1061
  typer.echo(_format_item(
992
1062
  item,
@@ -994,6 +1064,7 @@ def get(
994
1064
  version_nav=version_nav,
995
1065
  viewing_offset=offset if offset > 0 else None,
996
1066
  similar_items=similar_items,
1067
+ similar_offsets=similar_offsets,
997
1068
  ))
998
1069
 
999
1070
 
@@ -1019,6 +1090,10 @@ def list_collections(
1019
1090
 
1020
1091
  @app.command()
1021
1092
  def init(
1093
+ reset_system_docs: Annotated[bool, typer.Option(
1094
+ "--reset-system-docs",
1095
+ help="Force reload system documents from bundled content (overwrites modifications)"
1096
+ )] = False,
1022
1097
  store: StoreOption = None,
1023
1098
  collection: CollectionOption = "default",
1024
1099
  ):
@@ -1027,6 +1102,11 @@ def init(
1027
1102
  """
1028
1103
  kp = _get_keeper(store, collection)
1029
1104
 
1105
+ # Handle reset if requested
1106
+ if reset_system_docs:
1107
+ stats = kp.reset_system_documents()
1108
+ typer.echo(f"Reset {stats['reset']} system documents")
1109
+
1030
1110
  # Show config and store paths
1031
1111
  config = kp._config
1032
1112
  config_path = config.config_path if config else None
keep/config.py CHANGED
@@ -14,14 +14,12 @@ 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"
24
21
  CONFIG_VERSION = 3 # Bumped for document versioning support
22
+ SYSTEM_DOCS_VERSION = 1 # Increment when bundled system docs content changes
25
23
 
26
24
 
27
25
  @dataclass
@@ -91,6 +89,9 @@ class StoreConfig:
91
89
  # Maximum length for summaries (used for smart remember and validation)
92
90
  max_summary_length: int = 500
93
91
 
92
+ # System docs version (tracks which bundled docs have been applied to this store)
93
+ system_docs_version: int = 0
94
+
94
95
  @property
95
96
  def config_path(self) -> Path:
96
97
  """Path to the TOML config file."""
@@ -354,6 +355,9 @@ def load_config(config_dir: Path) -> StoreConfig:
354
355
  # Parse max_summary_length (default 500)
355
356
  max_summary_length = data.get("store", {}).get("max_summary_length", 500)
356
357
 
358
+ # Parse system_docs_version (default 0 for stores that predate this feature)
359
+ system_docs_version = data.get("store", {}).get("system_docs_version", 0)
360
+
357
361
  return StoreConfig(
358
362
  path=actual_store,
359
363
  config_dir=config_dir,
@@ -366,6 +370,7 @@ def load_config(config_dir: Path) -> StoreConfig:
366
370
  embedding_identity=parse_embedding_identity(data.get("embedding_identity")),
367
371
  default_tags=default_tags,
368
372
  max_summary_length=max_summary_length,
373
+ system_docs_version=system_docs_version,
369
374
  )
370
375
 
371
376
 
@@ -387,9 +392,6 @@ def save_config(config: StoreConfig) -> None:
387
392
 
388
393
  Creates the directory if it doesn't exist.
389
394
  """
390
- if tomli_w is None:
391
- raise RuntimeError("tomli_w is required to save config. Install with: pip install tomli-w")
392
-
393
395
  # Ensure config directory exists
394
396
  config_location = config.config_dir if config.config_dir else config.path
395
397
  config_location.mkdir(parents=True, exist_ok=True)
@@ -410,6 +412,9 @@ def save_config(config: StoreConfig) -> None:
410
412
  # Only write max_summary_length if not default
411
413
  if config.max_summary_length != 500:
412
414
  store_section["max_summary_length"] = config.max_summary_length
415
+ # Write system_docs_version if set (tracks migration state)
416
+ if config.system_docs_version > 0:
417
+ store_section["system_docs_version"] = config.system_docs_version
413
418
 
414
419
  data = {
415
420
  "store": store_section,
keep/data/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Package data for keep
@@ -0,0 +1 @@
1
+ # System documents for keep