keep-skill 0.1.0__py3-none-any.whl → 0.2.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/__init__.py +3 -6
- keep/api.py +793 -141
- keep/cli.py +467 -129
- keep/config.py +172 -41
- keep/context.py +1 -125
- keep/document_store.py +569 -0
- keep/errors.py +33 -0
- keep/indexing.py +1 -1
- keep/logging_config.py +34 -3
- keep/paths.py +81 -17
- keep/pending_summaries.py +46 -40
- keep/providers/embedding_cache.py +53 -46
- keep/providers/embeddings.py +43 -13
- keep/providers/mlx.py +23 -21
- keep/store.py +58 -14
- {keep_skill-0.1.0.dist-info → keep_skill-0.2.0.dist-info}/METADATA +29 -15
- keep_skill-0.2.0.dist-info/RECORD +28 -0
- keep_skill-0.1.0.dist-info/RECORD +0 -26
- {keep_skill-0.1.0.dist-info → keep_skill-0.2.0.dist-info}/WHEEL +0 -0
- {keep_skill-0.1.0.dist-info → keep_skill-0.2.0.dist-info}/entry_points.txt +0 -0
- {keep_skill-0.1.0.dist-info → keep_skill-0.2.0.dist-info}/licenses/LICENSE +0 -0
keep/cli.py
CHANGED
|
@@ -18,22 +18,99 @@ from typing_extensions import Annotated
|
|
|
18
18
|
|
|
19
19
|
from .api import Keeper
|
|
20
20
|
from .types import Item
|
|
21
|
-
from .logging_config import configure_quiet_mode
|
|
21
|
+
from .logging_config import configure_quiet_mode, enable_debug_mode
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
# Configure quiet mode by default (suppress verbose library output)
|
|
25
|
-
# Set KEEP_VERBOSE=1 to enable
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
# Set KEEP_VERBOSE=1 to enable debug mode via environment
|
|
26
|
+
if os.environ.get("KEEP_VERBOSE") == "1":
|
|
27
|
+
enable_debug_mode()
|
|
28
|
+
else:
|
|
29
|
+
configure_quiet_mode(quiet=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _verbose_callback(value: bool):
|
|
33
|
+
if value:
|
|
34
|
+
enable_debug_mode()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Global state for CLI options
|
|
38
|
+
_json_output = False
|
|
39
|
+
_ids_output = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _json_callback(value: bool):
|
|
43
|
+
global _json_output
|
|
44
|
+
_json_output = value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_json_output() -> bool:
|
|
48
|
+
return _json_output
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ids_callback(value: bool):
|
|
52
|
+
global _ids_output
|
|
53
|
+
_ids_output = value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_ids_output() -> bool:
|
|
57
|
+
return _ids_output
|
|
28
58
|
|
|
29
59
|
|
|
30
60
|
app = typer.Typer(
|
|
31
61
|
name="keep",
|
|
32
62
|
help="Associative memory with semantic search.",
|
|
33
|
-
no_args_is_help=
|
|
63
|
+
no_args_is_help=False,
|
|
64
|
+
invoke_without_command=True,
|
|
34
65
|
)
|
|
35
66
|
|
|
36
67
|
|
|
68
|
+
def _format_yaml_frontmatter(item: Item) -> str:
|
|
69
|
+
"""Format item as YAML frontmatter with summary as content."""
|
|
70
|
+
lines = ["---", f"id: {item.id}"]
|
|
71
|
+
if item.tags:
|
|
72
|
+
lines.append("tags:")
|
|
73
|
+
for k, v in sorted(item.tags.items()):
|
|
74
|
+
lines.append(f" {k}: {v}")
|
|
75
|
+
if item.score is not None:
|
|
76
|
+
lines.append(f"score: {item.score:.3f}")
|
|
77
|
+
lines.append("---")
|
|
78
|
+
lines.append(item.summary) # Summary IS the content
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.callback(invoke_without_command=True)
|
|
83
|
+
def main_callback(
|
|
84
|
+
ctx: typer.Context,
|
|
85
|
+
verbose: Annotated[bool, typer.Option(
|
|
86
|
+
"--verbose", "-v",
|
|
87
|
+
help="Enable debug-level logging to stderr",
|
|
88
|
+
callback=_verbose_callback,
|
|
89
|
+
is_eager=True,
|
|
90
|
+
)] = False,
|
|
91
|
+
output_json: Annotated[bool, typer.Option(
|
|
92
|
+
"--json", "-j",
|
|
93
|
+
help="Output as JSON",
|
|
94
|
+
callback=_json_callback,
|
|
95
|
+
is_eager=True,
|
|
96
|
+
)] = False,
|
|
97
|
+
ids_only: Annotated[bool, typer.Option(
|
|
98
|
+
"--ids", "-I",
|
|
99
|
+
help="Output only IDs (for piping to xargs)",
|
|
100
|
+
callback=_ids_callback,
|
|
101
|
+
is_eager=True,
|
|
102
|
+
)] = False,
|
|
103
|
+
):
|
|
104
|
+
"""Associative memory with semantic search."""
|
|
105
|
+
# If no subcommand provided, show the current context (now)
|
|
106
|
+
if ctx.invoked_subcommand is None:
|
|
107
|
+
kp = _get_keeper(None, "default")
|
|
108
|
+
item = kp.get_now()
|
|
109
|
+
typer.echo(_format_item(item, as_json=_get_json_output()))
|
|
110
|
+
if not _get_json_output():
|
|
111
|
+
typer.echo("\nUse --help for commands.", err=True)
|
|
112
|
+
|
|
113
|
+
|
|
37
114
|
# -----------------------------------------------------------------------------
|
|
38
115
|
# Common Options
|
|
39
116
|
# -----------------------------------------------------------------------------
|
|
@@ -63,11 +140,12 @@ LimitOption = Annotated[
|
|
|
63
140
|
)
|
|
64
141
|
]
|
|
65
142
|
|
|
66
|
-
|
|
67
|
-
|
|
143
|
+
|
|
144
|
+
SinceOption = Annotated[
|
|
145
|
+
Optional[str],
|
|
68
146
|
typer.Option(
|
|
69
|
-
"--
|
|
70
|
-
help="
|
|
147
|
+
"--since",
|
|
148
|
+
help="Only items updated since (ISO duration: P3D, P1W, PT1H; or date: 2026-01-15)"
|
|
71
149
|
)
|
|
72
150
|
]
|
|
73
151
|
|
|
@@ -77,6 +155,15 @@ JsonOption = Annotated[
|
|
|
77
155
|
# -----------------------------------------------------------------------------
|
|
78
156
|
|
|
79
157
|
def _format_item(item: Item, as_json: bool = False) -> str:
|
|
158
|
+
"""
|
|
159
|
+
Format an item for display.
|
|
160
|
+
|
|
161
|
+
Text format: YAML frontmatter (matches docs/system format)
|
|
162
|
+
With --ids: just the ID (for piping)
|
|
163
|
+
"""
|
|
164
|
+
if _get_ids_output():
|
|
165
|
+
return json.dumps(item.id) if as_json else item.id
|
|
166
|
+
|
|
80
167
|
if as_json:
|
|
81
168
|
return json.dumps({
|
|
82
169
|
"id": item.id,
|
|
@@ -84,12 +171,16 @@ def _format_item(item: Item, as_json: bool = False) -> str:
|
|
|
84
171
|
"tags": item.tags,
|
|
85
172
|
"score": item.score,
|
|
86
173
|
})
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return f"{score}{item.id}\n {item.summary}"
|
|
174
|
+
|
|
175
|
+
return _format_yaml_frontmatter(item)
|
|
90
176
|
|
|
91
177
|
|
|
92
178
|
def _format_items(items: list[Item], as_json: bool = False) -> str:
|
|
179
|
+
"""Format multiple items for display."""
|
|
180
|
+
if _get_ids_output():
|
|
181
|
+
ids = [item.id for item in items]
|
|
182
|
+
return json.dumps(ids) if as_json else "\n".join(ids)
|
|
183
|
+
|
|
93
184
|
if as_json:
|
|
94
185
|
return json.dumps([
|
|
95
186
|
{
|
|
@@ -116,41 +207,80 @@ def _get_keeper(store: Optional[Path], collection: str) -> Keeper:
|
|
|
116
207
|
raise typer.Exit(1)
|
|
117
208
|
|
|
118
209
|
|
|
210
|
+
def _parse_tags(tags: Optional[list[str]]) -> dict[str, str]:
|
|
211
|
+
"""Parse key=value tag list to dict."""
|
|
212
|
+
if not tags:
|
|
213
|
+
return {}
|
|
214
|
+
parsed = {}
|
|
215
|
+
for tag in tags:
|
|
216
|
+
if "=" not in tag:
|
|
217
|
+
typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
|
|
218
|
+
raise typer.Exit(1)
|
|
219
|
+
k, v = tag.split("=", 1)
|
|
220
|
+
parsed[k] = v
|
|
221
|
+
return parsed
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _timestamp() -> str:
|
|
225
|
+
"""Generate timestamp for auto-generated IDs."""
|
|
226
|
+
from datetime import datetime, timezone
|
|
227
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _parse_frontmatter(text: str) -> tuple[str, dict[str, str]]:
|
|
231
|
+
"""Parse YAML frontmatter from text, return (content, tags)."""
|
|
232
|
+
if text.startswith("---"):
|
|
233
|
+
parts = text.split("---", 2)
|
|
234
|
+
if len(parts) >= 3:
|
|
235
|
+
import yaml
|
|
236
|
+
frontmatter = yaml.safe_load(parts[1])
|
|
237
|
+
content = parts[2].lstrip("\n")
|
|
238
|
+
tags = frontmatter.get("tags", {}) if frontmatter else {}
|
|
239
|
+
return content, {k: str(v) for k, v in tags.items()}
|
|
240
|
+
return text, {}
|
|
241
|
+
|
|
242
|
+
|
|
119
243
|
# -----------------------------------------------------------------------------
|
|
120
244
|
# Commands
|
|
121
245
|
# -----------------------------------------------------------------------------
|
|
122
246
|
|
|
123
247
|
@app.command()
|
|
124
248
|
def find(
|
|
125
|
-
query: Annotated[str, typer.Argument(help="Search query text")],
|
|
249
|
+
query: Annotated[Optional[str], typer.Argument(help="Search query text")] = None,
|
|
250
|
+
id: Annotated[Optional[str], typer.Option(
|
|
251
|
+
"--id",
|
|
252
|
+
help="Find items similar to this ID (instead of text search)"
|
|
253
|
+
)] = None,
|
|
254
|
+
include_self: Annotated[bool, typer.Option(
|
|
255
|
+
help="Include the queried item (only with --id)"
|
|
256
|
+
)] = False,
|
|
126
257
|
store: StoreOption = None,
|
|
127
258
|
collection: CollectionOption = "default",
|
|
128
259
|
limit: LimitOption = 10,
|
|
129
|
-
|
|
260
|
+
since: SinceOption = None,
|
|
130
261
|
):
|
|
131
262
|
"""
|
|
132
263
|
Find items using semantic similarity search.
|
|
264
|
+
|
|
265
|
+
Examples:
|
|
266
|
+
keep find "authentication" # Search by text
|
|
267
|
+
keep find --id file:///path/to/doc.md # Find similar to item
|
|
133
268
|
"""
|
|
269
|
+
if id and query:
|
|
270
|
+
typer.echo("Error: Specify either a query or --id, not both", err=True)
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
if not id and not query:
|
|
273
|
+
typer.echo("Error: Specify a query or --id", err=True)
|
|
274
|
+
raise typer.Exit(1)
|
|
275
|
+
|
|
134
276
|
kp = _get_keeper(store, collection)
|
|
135
|
-
results = kp.find(query, limit=limit)
|
|
136
|
-
typer.echo(_format_items(results, as_json=output_json))
|
|
137
277
|
|
|
278
|
+
if id:
|
|
279
|
+
results = kp.find_similar(id, limit=limit, since=since, include_self=include_self)
|
|
280
|
+
else:
|
|
281
|
+
results = kp.find(query, limit=limit, since=since)
|
|
138
282
|
|
|
139
|
-
|
|
140
|
-
def similar(
|
|
141
|
-
id: Annotated[str, typer.Argument(help="URI of item to find similar items for")],
|
|
142
|
-
store: StoreOption = None,
|
|
143
|
-
collection: CollectionOption = "default",
|
|
144
|
-
limit: LimitOption = 10,
|
|
145
|
-
include_self: Annotated[bool, typer.Option(help="Include the queried item")] = False,
|
|
146
|
-
output_json: JsonOption = False,
|
|
147
|
-
):
|
|
148
|
-
"""
|
|
149
|
-
Find items similar to an existing item.
|
|
150
|
-
"""
|
|
151
|
-
kp = _get_keeper(store, collection)
|
|
152
|
-
results = kp.find_similar(id, limit=limit, include_self=include_self)
|
|
153
|
-
typer.echo(_format_items(results, as_json=output_json))
|
|
283
|
+
typer.echo(_format_items(results, as_json=_get_json_output()))
|
|
154
284
|
|
|
155
285
|
|
|
156
286
|
@app.command()
|
|
@@ -159,109 +289,265 @@ def search(
|
|
|
159
289
|
store: StoreOption = None,
|
|
160
290
|
collection: CollectionOption = "default",
|
|
161
291
|
limit: LimitOption = 10,
|
|
162
|
-
|
|
292
|
+
since: SinceOption = None,
|
|
163
293
|
):
|
|
164
294
|
"""
|
|
165
295
|
Search item summaries using full-text search.
|
|
166
296
|
"""
|
|
167
297
|
kp = _get_keeper(store, collection)
|
|
168
|
-
results = kp.query_fulltext(query, limit=limit)
|
|
169
|
-
typer.echo(_format_items(results, as_json=
|
|
298
|
+
results = kp.query_fulltext(query, limit=limit, since=since)
|
|
299
|
+
typer.echo(_format_items(results, as_json=_get_json_output()))
|
|
170
300
|
|
|
171
301
|
|
|
172
302
|
@app.command()
|
|
173
303
|
def tag(
|
|
174
|
-
|
|
175
|
-
|
|
304
|
+
query: Annotated[Optional[str], typer.Argument(
|
|
305
|
+
help="Tag key to list values, or key=value to find docs"
|
|
306
|
+
)] = None,
|
|
307
|
+
list_keys: Annotated[bool, typer.Option(
|
|
308
|
+
"--list", "-l",
|
|
309
|
+
help="List all distinct tag keys"
|
|
310
|
+
)] = False,
|
|
176
311
|
store: StoreOption = None,
|
|
177
312
|
collection: CollectionOption = "default",
|
|
178
313
|
limit: LimitOption = 100,
|
|
179
|
-
|
|
314
|
+
since: SinceOption = None,
|
|
180
315
|
):
|
|
181
316
|
"""
|
|
182
|
-
|
|
317
|
+
List tag values or find items by tag.
|
|
318
|
+
|
|
319
|
+
Examples:
|
|
320
|
+
keep tag --list # List all tag keys
|
|
321
|
+
keep tag project # List values for 'project' tag
|
|
322
|
+
keep tag project=myapp # Find docs with project=myapp
|
|
183
323
|
"""
|
|
184
324
|
kp = _get_keeper(store, collection)
|
|
185
|
-
results = kp.query_tag(key, value, limit=limit)
|
|
186
|
-
typer.echo(_format_items(results, as_json=output_json))
|
|
187
325
|
|
|
326
|
+
# List all keys mode
|
|
327
|
+
if list_keys or query is None:
|
|
328
|
+
tags = kp.list_tags(None, collection=collection)
|
|
329
|
+
if _get_json_output():
|
|
330
|
+
typer.echo(json.dumps(tags))
|
|
331
|
+
else:
|
|
332
|
+
if not tags:
|
|
333
|
+
typer.echo("No tags found.")
|
|
334
|
+
else:
|
|
335
|
+
for t in tags:
|
|
336
|
+
typer.echo(t)
|
|
337
|
+
return
|
|
188
338
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
339
|
+
# Check if query is key=value or just key
|
|
340
|
+
if "=" in query:
|
|
341
|
+
# key=value → find documents
|
|
342
|
+
key, value = query.split("=", 1)
|
|
343
|
+
results = kp.query_tag(key, value, limit=limit, since=since)
|
|
344
|
+
typer.echo(_format_items(results, as_json=_get_json_output()))
|
|
345
|
+
else:
|
|
346
|
+
# key only → list values
|
|
347
|
+
values = kp.list_tags(query, collection=collection)
|
|
348
|
+
if _get_json_output():
|
|
349
|
+
typer.echo(json.dumps(values))
|
|
350
|
+
else:
|
|
351
|
+
if not values:
|
|
352
|
+
typer.echo(f"No values for tag '{query}'.")
|
|
353
|
+
else:
|
|
354
|
+
for v in values:
|
|
355
|
+
typer.echo(v)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@app.command("tag-update")
|
|
359
|
+
def tag_update(
|
|
360
|
+
ids: Annotated[list[str], typer.Argument(help="Document IDs to tag")],
|
|
194
361
|
tags: Annotated[Optional[list[str]], typer.Option(
|
|
195
362
|
"--tag", "-t",
|
|
196
|
-
help="
|
|
363
|
+
help="Tag as key=value (empty value removes: key=)"
|
|
197
364
|
)] = None,
|
|
198
|
-
|
|
199
|
-
"--
|
|
200
|
-
help="
|
|
201
|
-
)] =
|
|
202
|
-
|
|
365
|
+
remove: Annotated[Optional[list[str]], typer.Option(
|
|
366
|
+
"--remove", "-r",
|
|
367
|
+
help="Tag keys to remove"
|
|
368
|
+
)] = None,
|
|
369
|
+
store: StoreOption = None,
|
|
370
|
+
collection: CollectionOption = "default",
|
|
203
371
|
):
|
|
204
372
|
"""
|
|
205
|
-
Add or
|
|
373
|
+
Add, update, or remove tags on existing documents.
|
|
374
|
+
|
|
375
|
+
Does not re-process the document - only updates tags.
|
|
206
376
|
|
|
207
|
-
|
|
208
|
-
|
|
377
|
+
Examples:
|
|
378
|
+
keep tag-update doc:1 --tag project=myapp
|
|
379
|
+
keep tag-update doc:1 doc:2 --tag status=reviewed
|
|
380
|
+
keep tag-update doc:1 --remove obsolete_tag
|
|
381
|
+
keep tag-update doc:1 --tag temp= # Remove via empty value
|
|
209
382
|
"""
|
|
210
383
|
kp = _get_keeper(store, collection)
|
|
211
384
|
|
|
212
385
|
# Parse tags from key=value format
|
|
213
|
-
|
|
386
|
+
tag_changes: dict[str, str] = {}
|
|
214
387
|
if tags:
|
|
215
388
|
for tag in tags:
|
|
216
389
|
if "=" not in tag:
|
|
217
|
-
typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
|
|
390
|
+
typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value (or key= to remove)", err=True)
|
|
218
391
|
raise typer.Exit(1)
|
|
219
392
|
k, v = tag.split("=", 1)
|
|
220
|
-
|
|
393
|
+
tag_changes[k] = v # Empty v means delete
|
|
394
|
+
|
|
395
|
+
# Add explicit removals as empty strings
|
|
396
|
+
if remove:
|
|
397
|
+
for key in remove:
|
|
398
|
+
tag_changes[key] = ""
|
|
399
|
+
|
|
400
|
+
if not tag_changes:
|
|
401
|
+
typer.echo("Error: Specify at least one --tag or --remove", err=True)
|
|
402
|
+
raise typer.Exit(1)
|
|
403
|
+
|
|
404
|
+
# Process each document
|
|
405
|
+
results = []
|
|
406
|
+
for doc_id in ids:
|
|
407
|
+
item = kp.tag(doc_id, tags=tag_changes, collection=collection)
|
|
408
|
+
if item is None:
|
|
409
|
+
typer.echo(f"Not found: {doc_id}", err=True)
|
|
410
|
+
else:
|
|
411
|
+
results.append(item)
|
|
221
412
|
|
|
222
|
-
|
|
223
|
-
|
|
413
|
+
if _get_json_output():
|
|
414
|
+
typer.echo(_format_items(results, as_json=True))
|
|
415
|
+
else:
|
|
416
|
+
for item in results:
|
|
417
|
+
typer.echo(_format_item(item, as_json=False))
|
|
224
418
|
|
|
225
419
|
|
|
226
420
|
@app.command()
|
|
227
|
-
def
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
421
|
+
def update(
|
|
422
|
+
source: Annotated[Optional[str], typer.Argument(
|
|
423
|
+
help="URI to fetch, text content, or '-' for stdin"
|
|
424
|
+
)] = None,
|
|
231
425
|
id: Annotated[Optional[str], typer.Option(
|
|
232
426
|
"--id", "-i",
|
|
233
|
-
help="
|
|
427
|
+
help="Document ID (auto-generated for text/stdin modes)"
|
|
234
428
|
)] = None,
|
|
429
|
+
store: StoreOption = None,
|
|
430
|
+
collection: CollectionOption = "default",
|
|
235
431
|
tags: Annotated[Optional[list[str]], typer.Option(
|
|
236
432
|
"--tag", "-t",
|
|
237
|
-
help="
|
|
433
|
+
help="Tag as key=value (can be repeated)"
|
|
434
|
+
)] = None,
|
|
435
|
+
summary: Annotated[Optional[str], typer.Option(
|
|
436
|
+
"--summary",
|
|
437
|
+
help="User-provided summary (skips auto-summarization)"
|
|
238
438
|
)] = None,
|
|
239
439
|
lazy: Annotated[bool, typer.Option(
|
|
240
|
-
"--lazy",
|
|
440
|
+
"--lazy",
|
|
241
441
|
help="Fast mode: use truncated summary, queue for later processing"
|
|
242
442
|
)] = False,
|
|
243
|
-
output_json: JsonOption = False,
|
|
244
443
|
):
|
|
245
444
|
"""
|
|
246
|
-
|
|
445
|
+
Add or update a document in the store.
|
|
247
446
|
|
|
248
|
-
|
|
249
|
-
|
|
447
|
+
Three input modes (auto-detected):
|
|
448
|
+
keep update file:///path # URI mode: has ://
|
|
449
|
+
keep update "my note" # Text mode: no ://
|
|
450
|
+
keep update - # Stdin mode: explicit -
|
|
451
|
+
echo "pipe" | keep update # Stdin mode: piped input
|
|
250
452
|
"""
|
|
251
453
|
kp = _get_keeper(store, collection)
|
|
454
|
+
parsed_tags = _parse_tags(tags)
|
|
455
|
+
|
|
456
|
+
# Determine mode based on source content
|
|
457
|
+
if source == "-" or (source is None and not sys.stdin.isatty()):
|
|
458
|
+
# Stdin mode: explicit '-' or piped input
|
|
459
|
+
content = sys.stdin.read()
|
|
460
|
+
content, frontmatter_tags = _parse_frontmatter(content)
|
|
461
|
+
parsed_tags = {**frontmatter_tags, **parsed_tags} # CLI tags override
|
|
462
|
+
doc_id = id or f"mem:{_timestamp()}"
|
|
463
|
+
item = kp.remember(content, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
|
|
464
|
+
elif source and "://" in source:
|
|
465
|
+
# URI mode: fetch from URI (ID is the URI itself)
|
|
466
|
+
item = kp.update(source, tags=parsed_tags or None, summary=summary, lazy=lazy)
|
|
467
|
+
elif source:
|
|
468
|
+
# Text mode: inline content (no :// in source)
|
|
469
|
+
doc_id = id or f"mem:{_timestamp()}"
|
|
470
|
+
item = kp.remember(source, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
|
|
471
|
+
else:
|
|
472
|
+
typer.echo("Error: Provide content, URI, or '-' for stdin", err=True)
|
|
473
|
+
raise typer.Exit(1)
|
|
474
|
+
|
|
475
|
+
typer.echo(_format_item(item, as_json=_get_json_output()))
|
|
252
476
|
|
|
253
|
-
# Parse tags from key=value format
|
|
254
|
-
source_tags = {}
|
|
255
|
-
if tags:
|
|
256
|
-
for tag in tags:
|
|
257
|
-
if "=" not in tag:
|
|
258
|
-
typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
|
|
259
|
-
raise typer.Exit(1)
|
|
260
|
-
k, v = tag.split("=", 1)
|
|
261
|
-
source_tags[k] = v
|
|
262
477
|
|
|
263
|
-
|
|
264
|
-
|
|
478
|
+
@app.command()
|
|
479
|
+
def now(
|
|
480
|
+
content: Annotated[Optional[str], typer.Argument(
|
|
481
|
+
help="Content to set (omit to show current)"
|
|
482
|
+
)] = None,
|
|
483
|
+
file: Annotated[Optional[Path], typer.Option(
|
|
484
|
+
"--file", "-f",
|
|
485
|
+
help="Read content from file"
|
|
486
|
+
)] = None,
|
|
487
|
+
reset: Annotated[bool, typer.Option(
|
|
488
|
+
"--reset",
|
|
489
|
+
help="Reset to default from system"
|
|
490
|
+
)] = False,
|
|
491
|
+
store: StoreOption = None,
|
|
492
|
+
collection: CollectionOption = "default",
|
|
493
|
+
tags: Annotated[Optional[list[str]], typer.Option(
|
|
494
|
+
"--tag", "-t",
|
|
495
|
+
help="Tag as key=value (can be repeated)"
|
|
496
|
+
)] = None,
|
|
497
|
+
):
|
|
498
|
+
"""
|
|
499
|
+
Get or set the current working context.
|
|
500
|
+
|
|
501
|
+
With no arguments, displays the current context.
|
|
502
|
+
With content, replaces it.
|
|
503
|
+
|
|
504
|
+
Examples:
|
|
505
|
+
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
|
|
509
|
+
"""
|
|
510
|
+
kp = _get_keeper(store, collection)
|
|
511
|
+
|
|
512
|
+
# Determine if we're getting or setting
|
|
513
|
+
setting = content is not None or file is not None or reset
|
|
514
|
+
|
|
515
|
+
if setting:
|
|
516
|
+
if reset:
|
|
517
|
+
# Reset to default from system (delete first to clear old tags)
|
|
518
|
+
from .api import _load_frontmatter, NOWDOC_ID, SYSTEM_DOC_DIR
|
|
519
|
+
kp.delete(NOWDOC_ID)
|
|
520
|
+
try:
|
|
521
|
+
new_content, default_tags = _load_frontmatter(SYSTEM_DOC_DIR / "now.md")
|
|
522
|
+
parsed_tags = default_tags
|
|
523
|
+
except FileNotFoundError:
|
|
524
|
+
typer.echo("Error: Builtin now.md not found", err=True)
|
|
525
|
+
raise typer.Exit(1)
|
|
526
|
+
elif file is not None:
|
|
527
|
+
if not file.exists():
|
|
528
|
+
typer.echo(f"Error: File not found: {file}", err=True)
|
|
529
|
+
raise typer.Exit(1)
|
|
530
|
+
new_content = file.read_text()
|
|
531
|
+
parsed_tags = {}
|
|
532
|
+
else:
|
|
533
|
+
new_content = content
|
|
534
|
+
parsed_tags = {}
|
|
535
|
+
|
|
536
|
+
# Parse user-provided tags (merge with default if reset)
|
|
537
|
+
if tags:
|
|
538
|
+
for tag in tags:
|
|
539
|
+
if "=" not in tag:
|
|
540
|
+
typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
|
|
541
|
+
raise typer.Exit(1)
|
|
542
|
+
k, v = tag.split("=", 1)
|
|
543
|
+
parsed_tags[k] = v
|
|
544
|
+
|
|
545
|
+
item = kp.set_now(new_content, tags=parsed_tags or None)
|
|
546
|
+
typer.echo(_format_item(item, as_json=_get_json_output()))
|
|
547
|
+
else:
|
|
548
|
+
# Get current context
|
|
549
|
+
item = kp.get_now()
|
|
550
|
+
typer.echo(_format_item(item, as_json=_get_json_output()))
|
|
265
551
|
|
|
266
552
|
|
|
267
553
|
@app.command()
|
|
@@ -269,19 +555,18 @@ def get(
|
|
|
269
555
|
id: Annotated[str, typer.Argument(help="URI of item to retrieve")],
|
|
270
556
|
store: StoreOption = None,
|
|
271
557
|
collection: CollectionOption = "default",
|
|
272
|
-
output_json: JsonOption = False,
|
|
273
558
|
):
|
|
274
559
|
"""
|
|
275
560
|
Retrieve a specific item by ID.
|
|
276
561
|
"""
|
|
277
562
|
kp = _get_keeper(store, collection)
|
|
278
563
|
item = kp.get(id)
|
|
279
|
-
|
|
564
|
+
|
|
280
565
|
if item is None:
|
|
281
566
|
typer.echo(f"Not found: {id}", err=True)
|
|
282
567
|
raise typer.Exit(1)
|
|
283
|
-
|
|
284
|
-
typer.echo(_format_item(item, as_json=
|
|
568
|
+
|
|
569
|
+
typer.echo(_format_item(item, as_json=_get_json_output()))
|
|
285
570
|
|
|
286
571
|
|
|
287
572
|
@app.command()
|
|
@@ -306,15 +591,14 @@ def exists(
|
|
|
306
591
|
@app.command("collections")
|
|
307
592
|
def list_collections(
|
|
308
593
|
store: StoreOption = None,
|
|
309
|
-
output_json: JsonOption = False,
|
|
310
594
|
):
|
|
311
595
|
"""
|
|
312
596
|
List all collections in the store.
|
|
313
597
|
"""
|
|
314
598
|
kp = _get_keeper(store, "default")
|
|
315
599
|
collections = kp.list_collections()
|
|
316
|
-
|
|
317
|
-
if
|
|
600
|
+
|
|
601
|
+
if _get_json_output():
|
|
318
602
|
typer.echo(json.dumps(collections))
|
|
319
603
|
else:
|
|
320
604
|
if not collections:
|
|
@@ -333,58 +617,101 @@ def init(
|
|
|
333
617
|
Initialize or verify the store is ready.
|
|
334
618
|
"""
|
|
335
619
|
kp = _get_keeper(store, collection)
|
|
336
|
-
|
|
337
|
-
# Show
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
620
|
+
|
|
621
|
+
# Show config and store paths
|
|
622
|
+
config = kp._config
|
|
623
|
+
config_path = config.config_path if config else None
|
|
624
|
+
store_path = kp._store_path
|
|
625
|
+
|
|
626
|
+
# Show paths
|
|
627
|
+
typer.echo(f"Config: {config_path}")
|
|
628
|
+
if config and config.config_dir and config.config_dir.resolve() != store_path.resolve():
|
|
629
|
+
typer.echo(f"Store: {store_path}")
|
|
630
|
+
|
|
631
|
+
typer.echo(f"Collections: {kp.list_collections()}")
|
|
632
|
+
|
|
342
633
|
# Show detected providers
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
typer.echo(f" Embedding: {config.embedding.name}")
|
|
348
|
-
typer.echo(f" Summarization: {config.summarization.name}")
|
|
349
|
-
typer.echo(f"\nTo customize, edit {actual_path}/keep.toml")
|
|
350
|
-
except Exception:
|
|
351
|
-
pass # Don't fail if provider detection doesn't work
|
|
352
|
-
|
|
353
|
-
# .gitignore reminder
|
|
354
|
-
typer.echo(f"\n⚠️ Remember to add .keep/ to .gitignore")
|
|
634
|
+
if config:
|
|
635
|
+
typer.echo(f"\nProviders:")
|
|
636
|
+
typer.echo(f" Embedding: {config.embedding.name}")
|
|
637
|
+
typer.echo(f" Summarization: {config.summarization.name}")
|
|
355
638
|
|
|
356
639
|
|
|
357
|
-
|
|
358
|
-
|
|
640
|
+
|
|
641
|
+
@app.command()
|
|
642
|
+
def config(
|
|
359
643
|
store: StoreOption = None,
|
|
360
|
-
output_json: JsonOption = False,
|
|
361
644
|
):
|
|
362
645
|
"""
|
|
363
|
-
|
|
646
|
+
Show current configuration and store location.
|
|
364
647
|
"""
|
|
365
648
|
kp = _get_keeper(store, "default")
|
|
366
|
-
docs = kp.list_system_documents()
|
|
367
|
-
typer.echo(_format_items(docs, as_json=output_json))
|
|
368
649
|
|
|
650
|
+
cfg = kp._config
|
|
651
|
+
config_path = cfg.config_path if cfg else None
|
|
652
|
+
store_path = kp._store_path
|
|
653
|
+
|
|
654
|
+
if _get_json_output():
|
|
655
|
+
result = {
|
|
656
|
+
"store": str(store_path),
|
|
657
|
+
"config": str(config_path) if config_path else None,
|
|
658
|
+
"collections": kp.list_collections(),
|
|
659
|
+
}
|
|
660
|
+
if cfg:
|
|
661
|
+
result["embedding"] = cfg.embedding.name
|
|
662
|
+
result["summarization"] = cfg.summarization.name
|
|
663
|
+
typer.echo(json.dumps(result, indent=2))
|
|
664
|
+
else:
|
|
665
|
+
# Show paths
|
|
666
|
+
typer.echo(f"Config: {config_path}")
|
|
667
|
+
if cfg and cfg.config_dir and cfg.config_dir.resolve() != store_path.resolve():
|
|
668
|
+
typer.echo(f"Store: {store_path}")
|
|
669
|
+
|
|
670
|
+
typer.echo(f"Collections: {kp.list_collections()}")
|
|
369
671
|
|
|
370
|
-
|
|
371
|
-
|
|
672
|
+
if cfg:
|
|
673
|
+
typer.echo(f"\nProviders:")
|
|
674
|
+
typer.echo(f" Embedding: {cfg.embedding.name}")
|
|
675
|
+
typer.echo(f" Summarization: {cfg.summarization.name}")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@app.command("system")
|
|
679
|
+
def list_system(
|
|
372
680
|
store: StoreOption = None,
|
|
373
|
-
output_json: JsonOption = False,
|
|
374
681
|
):
|
|
375
682
|
"""
|
|
376
|
-
|
|
683
|
+
List the system documents.
|
|
684
|
+
|
|
685
|
+
Shows ID and summary for each. Use `keep get ID` for full details.
|
|
377
686
|
"""
|
|
378
687
|
kp = _get_keeper(store, "default")
|
|
379
|
-
|
|
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
|
|
380
699
|
|
|
381
|
-
if
|
|
382
|
-
|
|
383
|
-
|
|
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))
|
|
384
705
|
else:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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}")
|
|
388
715
|
|
|
389
716
|
|
|
390
717
|
@app.command("process-pending")
|
|
@@ -403,7 +730,6 @@ def process_pending(
|
|
|
403
730
|
hidden=True,
|
|
404
731
|
help="Run as background daemon (used internally)"
|
|
405
732
|
)] = False,
|
|
406
|
-
output_json: JsonOption = False,
|
|
407
733
|
):
|
|
408
734
|
"""
|
|
409
735
|
Process pending summaries from lazy indexing.
|
|
@@ -452,7 +778,7 @@ def process_pending(
|
|
|
452
778
|
pending_before = kp.pending_count()
|
|
453
779
|
|
|
454
780
|
if pending_before == 0:
|
|
455
|
-
if
|
|
781
|
+
if _get_json_output():
|
|
456
782
|
typer.echo(json.dumps({"processed": 0, "remaining": 0}))
|
|
457
783
|
else:
|
|
458
784
|
typer.echo("No pending summaries.")
|
|
@@ -466,11 +792,11 @@ def process_pending(
|
|
|
466
792
|
total_processed += processed
|
|
467
793
|
if processed == 0:
|
|
468
794
|
break
|
|
469
|
-
if not
|
|
795
|
+
if not _get_json_output():
|
|
470
796
|
typer.echo(f" Processed {total_processed}...")
|
|
471
797
|
|
|
472
798
|
remaining = kp.pending_count()
|
|
473
|
-
if
|
|
799
|
+
if _get_json_output():
|
|
474
800
|
typer.echo(json.dumps({
|
|
475
801
|
"processed": total_processed,
|
|
476
802
|
"remaining": remaining
|
|
@@ -482,7 +808,7 @@ def process_pending(
|
|
|
482
808
|
processed = kp.process_pending(limit=limit)
|
|
483
809
|
remaining = kp.pending_count()
|
|
484
810
|
|
|
485
|
-
if
|
|
811
|
+
if _get_json_output():
|
|
486
812
|
typer.echo(json.dumps({
|
|
487
813
|
"processed": processed,
|
|
488
814
|
"remaining": remaining
|
|
@@ -496,7 +822,19 @@ def process_pending(
|
|
|
496
822
|
# -----------------------------------------------------------------------------
|
|
497
823
|
|
|
498
824
|
def main():
|
|
499
|
-
|
|
825
|
+
try:
|
|
826
|
+
app()
|
|
827
|
+
except SystemExit:
|
|
828
|
+
raise # Let typer handle exit codes
|
|
829
|
+
except KeyboardInterrupt:
|
|
830
|
+
raise SystemExit(130) # Standard exit code for Ctrl+C
|
|
831
|
+
except Exception as e:
|
|
832
|
+
# Log full traceback to file, show clean message to user
|
|
833
|
+
from .errors import log_exception, ERROR_LOG_PATH
|
|
834
|
+
log_exception(e, context="keep CLI")
|
|
835
|
+
typer.echo(f"Error: {e}", err=True)
|
|
836
|
+
typer.echo(f"Details logged to {ERROR_LOG_PATH}", err=True)
|
|
837
|
+
raise SystemExit(1)
|
|
500
838
|
|
|
501
839
|
|
|
502
840
|
if __name__ == "__main__":
|