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/__init__.py +1 -1
- keep/api.py +325 -11
- keep/cli.py +453 -83
- keep/config.py +2 -2
- keep/document_store.py +351 -12
- keep/pending_summaries.py +6 -0
- keep/providers/embedding_cache.py +6 -0
- keep/store.py +128 -11
- keep_skill-0.4.1.dist-info/METADATA +219 -0
- {keep_skill-0.2.0.dist-info → keep_skill-0.4.1.dist-info}/RECORD +13 -13
- keep_skill-0.2.0.dist-info/METADATA +0 -304
- {keep_skill-0.2.0.dist-info → keep_skill-0.4.1.dist-info}/WHEEL +0 -0
- {keep_skill-0.2.0.dist-info → keep_skill-0.4.1.dist-info}/entry_points.txt +0 -0
- {keep_skill-0.2.0.dist-info → keep_skill-0.4.1.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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(
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
507
|
-
keep now -f context.md #
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
859
|
+
# Parse @V{N} version identifier from ID (security: check literal first)
|
|
860
|
+
actual_id = id
|
|
861
|
+
version_from_id = None
|
|
568
862
|
|
|
569
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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,
|