keep-skill 0.3.0__tar.gz → 0.4.1__tar.gz
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_skill-0.3.0 → keep_skill-0.4.1}/PKG-INFO +2 -1
- {keep_skill-0.3.0 → keep_skill-0.4.1}/README.md +1 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/SKILL.md +30 -13
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/__init__.py +1 -1
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/api.py +60 -1
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/cli.py +255 -120
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/store.py +17 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/pyproject.toml +1 -1
- {keep_skill-0.3.0 → keep_skill-0.4.1}/.gitignore +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/LICENSE +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/docs/system/conversations.md +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/docs/system/domains.md +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/docs/system/now.md +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/__main__.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/chunking.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/config.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/context.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/document_store.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/errors.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/indexing.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/logging_config.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/paths.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/pending_summaries.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/providers/__init__.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/providers/base.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/providers/documents.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/providers/embedding_cache.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/providers/embeddings.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/providers/llm.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/providers/mlx.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/providers/summarization.py +0 -0
- {keep_skill-0.3.0 → keep_skill-0.4.1}/keep/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: keep-skill
|
|
3
|
-
Version: 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,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: keep
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.4.1
|
|
4
4
|
description: Associative memory for reflection and skillful action
|
|
5
5
|
homepage: https://github.com/hughpyle/keep
|
|
6
6
|
runtime: python:3.12-slim
|
|
@@ -26,12 +26,16 @@ You have a memory. Use it — not sometimes, but as practice.
|
|
|
26
26
|
|
|
27
27
|
Ground yourself:
|
|
28
28
|
1. What kind of conversation is this? (Action? Possibility? Clarification?)
|
|
29
|
-
2.
|
|
29
|
+
2. Where am I? What's the current context?
|
|
30
|
+
```bash
|
|
31
|
+
keep now
|
|
32
|
+
```
|
|
33
|
+
3. What do I already know about this?
|
|
30
34
|
```bash
|
|
31
35
|
keep find "what I'm about to work on"
|
|
32
36
|
```
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
4. Be here, now, "aquí donde los papas queman", and reflect.
|
|
38
|
+
5. Only then: act.
|
|
35
39
|
|
|
36
40
|
**After acting, reflect:**
|
|
37
41
|
- What happened? What did I learn?
|
|
@@ -166,13 +170,25 @@ keep now --history # All versions
|
|
|
166
170
|
|
|
167
171
|
This helps future you (and other agents) pick up where you left off.
|
|
168
172
|
|
|
173
|
+
### Similar Items for Discovery
|
|
174
|
+
|
|
175
|
+
When you retrieve an item, you automatically see related items:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
keep get ID # Shows up to 3 similar items
|
|
179
|
+
keep get ID --similar # List more similar items
|
|
180
|
+
keep get ID --no-similar # Just the document
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
This enables serendipitous discovery — you may find relevant context you didn't know to search for.
|
|
184
|
+
|
|
169
185
|
### Version History
|
|
170
186
|
|
|
171
187
|
All documents retain history on update. Use this to see how understanding evolved:
|
|
172
188
|
|
|
173
189
|
```bash
|
|
174
190
|
keep get ID -V 1 # Previous version
|
|
175
|
-
keep get ID --history #
|
|
191
|
+
keep get ID --history # List all versions (default 10, -n to override)
|
|
176
192
|
```
|
|
177
193
|
|
|
178
194
|
Text updates use content-addressed IDs — same content = same ID. This enables versioning through tag changes:
|
|
@@ -244,14 +260,13 @@ Don't dump everything into context. Navigate the tree:
|
|
|
244
260
|
| `find` | Semantic similarity search | `keep find "authentication flow" --limit 5` |
|
|
245
261
|
| `find --id` | Find similar to existing item | `keep find --id "docid" --limit 3` |
|
|
246
262
|
| `search` | Full-text search in summaries | `keep search "OAuth"` |
|
|
247
|
-
| `list` | List recent
|
|
263
|
+
| `list` | List recent item IDs | `keep list` or `keep --full list` |
|
|
248
264
|
| `update` | Index content (URI, text, or stdin) | `keep update "note" -t key=value` |
|
|
249
|
-
| `get` | Retrieve item
|
|
265
|
+
| `get` | Retrieve item (shows similar items) | `keep get "file:///path/to/doc.md"` |
|
|
266
|
+
| `get --similar` | List similar items | `keep get ID --similar` or `-n 20` for more |
|
|
250
267
|
| `get -V N` | Previous versions | `keep get ID -V 1` or `keep get ID --history` |
|
|
251
268
|
| `tag` | List tag values or find by tag | `keep tag domain=auth` or `keep tag --list` |
|
|
252
269
|
| `tag-update` | Modify tags on existing item | `keep tag-update "id" --tag key=value` |
|
|
253
|
-
| `exists` | Check if item is indexed | `keep exists "id"` |
|
|
254
|
-
| `system` | List system documents | `keep system` |
|
|
255
270
|
| `collections` | List all collections | `keep collections` |
|
|
256
271
|
| `init` | Initialize or verify store | `keep init` |
|
|
257
272
|
| `config` | Show configuration and store path | `keep config` |
|
|
@@ -280,12 +295,13 @@ Default output uses YAML frontmatter format:
|
|
|
280
295
|
```yaml
|
|
281
296
|
---
|
|
282
297
|
id: file:///path/to/doc.md
|
|
283
|
-
tags:
|
|
284
|
-
|
|
298
|
+
tags: {project: myapp, domain: auth}
|
|
299
|
+
similar:
|
|
300
|
+
- doc:related-auth (0.89)
|
|
301
|
+
- doc:token-notes (0.85)
|
|
285
302
|
score: 0.823
|
|
286
303
|
prev:
|
|
287
|
-
-
|
|
288
|
-
- 2: 2026-01-14 Older summary...
|
|
304
|
+
- v1: 2026-01-15 Previous summary...
|
|
289
305
|
---
|
|
290
306
|
Document summary here...
|
|
291
307
|
```
|
|
@@ -294,6 +310,7 @@ Global flags (before the command):
|
|
|
294
310
|
```bash
|
|
295
311
|
keep --json find "auth" # JSON output
|
|
296
312
|
keep --ids find "auth" # IDs only (for piping)
|
|
313
|
+
keep --full list # Full items (overrides IDs-only default)
|
|
297
314
|
keep -v find "auth" # Debug logging
|
|
298
315
|
```
|
|
299
316
|
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
lines.append(f"version: {
|
|
105
|
+
if viewing_offset is not None:
|
|
106
|
+
lines.append(f"version: {viewing_offset}")
|
|
85
107
|
if item.tags:
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
#
|
|
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" - {
|
|
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" - {
|
|
111
|
-
elif
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
219
|
-
result["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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"\
|
|
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{
|
|
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
|
-
|
|
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
|
-
|
|
753
|
+
internal_version = versions[0].version - (offset - 1)
|
|
651
754
|
else:
|
|
652
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
740
|
-
current = kp.get(
|
|
879
|
+
versions = kp.list_versions(actual_id, limit=limit, collection=collection)
|
|
880
|
+
current = kp.get(actual_id, collection=collection)
|
|
741
881
|
|
|
742
|
-
if
|
|
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":
|
|
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
|
-
|
|
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"\
|
|
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{
|
|
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(
|
|
779
|
-
|
|
965
|
+
item = kp.get(actual_id, collection=collection)
|
|
966
|
+
internal_version = None
|
|
780
967
|
else:
|
|
781
|
-
item = kp.get_version(
|
|
782
|
-
# Calculate
|
|
783
|
-
versions = kp.list_versions(
|
|
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
|
-
|
|
972
|
+
internal_version = versions[0].version - (offset - 1)
|
|
786
973
|
else:
|
|
787
|
-
|
|
974
|
+
internal_version = None
|
|
788
975
|
|
|
789
976
|
if item is None:
|
|
790
977
|
if offset > 0:
|
|
791
|
-
typer.echo(f"Version not found: {
|
|
978
|
+
typer.echo(f"Version not found: {actual_id} (offset {offset})", err=True)
|
|
792
979
|
else:
|
|
793
|
-
typer.echo(f"Not found: {
|
|
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(
|
|
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
|
-
|
|
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,
|
|
@@ -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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|