keep-skill 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
keep/cli.py ADDED
@@ -0,0 +1,503 @@
1
+ """
2
+ CLI interface for associative memory.
3
+
4
+ Usage:
5
+ keepfind "query text"
6
+ keepupdate file:///path/to/doc.md
7
+ keepget file:///path/to/doc.md
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import typer
17
+ from typing_extensions import Annotated
18
+
19
+ from .api import Keeper
20
+ from .types import Item
21
+ from .logging_config import configure_quiet_mode
22
+
23
+
24
+ # Configure quiet mode by default (suppress verbose library output)
25
+ # Set KEEP_VERBOSE=1 to enable verbose mode for debugging
26
+ _verbose = sys.argv and "--verbose" in sys.argv or os.environ.get("KEEP_VERBOSE") == "1"
27
+ configure_quiet_mode(quiet=not _verbose)
28
+
29
+
30
+ app = typer.Typer(
31
+ name="keep",
32
+ help="Associative memory with semantic search.",
33
+ no_args_is_help=True,
34
+ )
35
+
36
+
37
+ # -----------------------------------------------------------------------------
38
+ # Common Options
39
+ # -----------------------------------------------------------------------------
40
+
41
+ StoreOption = Annotated[
42
+ Optional[Path],
43
+ typer.Option(
44
+ "--store", "-s",
45
+ envvar="KEEP_STORE_PATH",
46
+ help="Path to the store directory (default: .keep/ at repo root)"
47
+ )
48
+ ]
49
+
50
+ CollectionOption = Annotated[
51
+ str,
52
+ typer.Option(
53
+ "--collection", "-c",
54
+ help="Collection name"
55
+ )
56
+ ]
57
+
58
+ LimitOption = Annotated[
59
+ int,
60
+ typer.Option(
61
+ "--limit", "-n",
62
+ help="Maximum results to return"
63
+ )
64
+ ]
65
+
66
+ JsonOption = Annotated[
67
+ bool,
68
+ typer.Option(
69
+ "--json", "-j",
70
+ help="Output as JSON"
71
+ )
72
+ ]
73
+
74
+
75
+ # -----------------------------------------------------------------------------
76
+ # Output Helpers
77
+ # -----------------------------------------------------------------------------
78
+
79
+ def _format_item(item: Item, as_json: bool = False) -> str:
80
+ if as_json:
81
+ return json.dumps({
82
+ "id": item.id,
83
+ "summary": item.summary,
84
+ "tags": item.tags,
85
+ "score": item.score,
86
+ })
87
+ else:
88
+ score = f"[{item.score:.3f}] " if item.score is not None else ""
89
+ return f"{score}{item.id}\n {item.summary}"
90
+
91
+
92
+ def _format_items(items: list[Item], as_json: bool = False) -> str:
93
+ if as_json:
94
+ return json.dumps([
95
+ {
96
+ "id": item.id,
97
+ "summary": item.summary,
98
+ "tags": item.tags,
99
+ "score": item.score,
100
+ }
101
+ for item in items
102
+ ], indent=2)
103
+ else:
104
+ if not items:
105
+ return "No results."
106
+ return "\n\n".join(_format_item(item, as_json=False) for item in items)
107
+
108
+
109
+ def _get_keeper(store: Optional[Path], collection: str) -> Keeper:
110
+ """Initialize memory, handling errors gracefully."""
111
+ # store=None is fine — Keeper will use default (git root/.keep)
112
+ try:
113
+ return Keeper(store, collection=collection)
114
+ except Exception as e:
115
+ typer.echo(f"Error: {e}", err=True)
116
+ raise typer.Exit(1)
117
+
118
+
119
+ # -----------------------------------------------------------------------------
120
+ # Commands
121
+ # -----------------------------------------------------------------------------
122
+
123
+ @app.command()
124
+ def find(
125
+ query: Annotated[str, typer.Argument(help="Search query text")],
126
+ store: StoreOption = None,
127
+ collection: CollectionOption = "default",
128
+ limit: LimitOption = 10,
129
+ output_json: JsonOption = False,
130
+ ):
131
+ """
132
+ Find items using semantic similarity search.
133
+ """
134
+ kp = _get_keeper(store, collection)
135
+ results = kp.find(query, limit=limit)
136
+ typer.echo(_format_items(results, as_json=output_json))
137
+
138
+
139
+ @app.command()
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))
154
+
155
+
156
+ @app.command()
157
+ def search(
158
+ query: Annotated[str, typer.Argument(help="Full-text search query")],
159
+ store: StoreOption = None,
160
+ collection: CollectionOption = "default",
161
+ limit: LimitOption = 10,
162
+ output_json: JsonOption = False,
163
+ ):
164
+ """
165
+ Search item summaries using full-text search.
166
+ """
167
+ kp = _get_keeper(store, collection)
168
+ results = kp.query_fulltext(query, limit=limit)
169
+ typer.echo(_format_items(results, as_json=output_json))
170
+
171
+
172
+ @app.command()
173
+ def tag(
174
+ key: Annotated[str, typer.Argument(help="Tag key to search for")],
175
+ value: Annotated[Optional[str], typer.Argument(help="Tag value (optional)")] = None,
176
+ store: StoreOption = None,
177
+ collection: CollectionOption = "default",
178
+ limit: LimitOption = 100,
179
+ output_json: JsonOption = False,
180
+ ):
181
+ """
182
+ Find items by tag.
183
+ """
184
+ 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
+
188
+
189
+ @app.command()
190
+ def update(
191
+ id: Annotated[str, typer.Argument(help="URI of document to index")],
192
+ store: StoreOption = None,
193
+ collection: CollectionOption = "default",
194
+ tags: Annotated[Optional[list[str]], typer.Option(
195
+ "--tag", "-t",
196
+ help="Source tag as key=value (can be repeated)"
197
+ )] = None,
198
+ lazy: Annotated[bool, typer.Option(
199
+ "--lazy", "-l",
200
+ help="Fast mode: use truncated summary, queue for later processing"
201
+ )] = False,
202
+ output_json: JsonOption = False,
203
+ ):
204
+ """
205
+ Add or update a document in the store.
206
+
207
+ Use --lazy for fast indexing when summarization is slow.
208
+ Run 'keep process-pending' later to generate real summaries.
209
+ """
210
+ kp = _get_keeper(store, collection)
211
+
212
+ # Parse tags from key=value format
213
+ source_tags = {}
214
+ if tags:
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
+ source_tags[k] = v
221
+
222
+ item = kp.update(id, source_tags=source_tags or None, lazy=lazy)
223
+ typer.echo(_format_item(item, as_json=output_json))
224
+
225
+
226
+ @app.command()
227
+ def remember(
228
+ content: Annotated[str, typer.Argument(help="Content to remember")],
229
+ store: StoreOption = None,
230
+ collection: CollectionOption = "default",
231
+ id: Annotated[Optional[str], typer.Option(
232
+ "--id", "-i",
233
+ help="Custom identifier (default: auto-generated)"
234
+ )] = None,
235
+ tags: Annotated[Optional[list[str]], typer.Option(
236
+ "--tag", "-t",
237
+ help="Source tag as key=value (can be repeated)"
238
+ )] = None,
239
+ lazy: Annotated[bool, typer.Option(
240
+ "--lazy", "-l",
241
+ help="Fast mode: use truncated summary, queue for later processing"
242
+ )] = False,
243
+ output_json: JsonOption = False,
244
+ ):
245
+ """
246
+ Remember inline content (conversations, notes, insights).
247
+
248
+ Use --lazy for fast indexing when summarization is slow.
249
+ Run 'keep process-pending' later to generate real summaries.
250
+ """
251
+ kp = _get_keeper(store, collection)
252
+
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
+
263
+ item = kp.remember(content, id=id, source_tags=source_tags or None, lazy=lazy)
264
+ typer.echo(_format_item(item, as_json=output_json))
265
+
266
+
267
+ @app.command()
268
+ def get(
269
+ id: Annotated[str, typer.Argument(help="URI of item to retrieve")],
270
+ store: StoreOption = None,
271
+ collection: CollectionOption = "default",
272
+ output_json: JsonOption = False,
273
+ ):
274
+ """
275
+ Retrieve a specific item by ID.
276
+ """
277
+ kp = _get_keeper(store, collection)
278
+ item = kp.get(id)
279
+
280
+ if item is None:
281
+ typer.echo(f"Not found: {id}", err=True)
282
+ raise typer.Exit(1)
283
+
284
+ typer.echo(_format_item(item, as_json=output_json))
285
+
286
+
287
+ @app.command()
288
+ def exists(
289
+ id: Annotated[str, typer.Argument(help="URI to check")],
290
+ store: StoreOption = None,
291
+ collection: CollectionOption = "default",
292
+ ):
293
+ """
294
+ Check if an item exists in the store.
295
+ """
296
+ kp = _get_keeper(store, collection)
297
+ found = kp.exists(id)
298
+
299
+ if found:
300
+ typer.echo(f"Exists: {id}")
301
+ else:
302
+ typer.echo(f"Not found: {id}")
303
+ raise typer.Exit(1)
304
+
305
+
306
+ @app.command("collections")
307
+ def list_collections(
308
+ store: StoreOption = None,
309
+ output_json: JsonOption = False,
310
+ ):
311
+ """
312
+ List all collections in the store.
313
+ """
314
+ kp = _get_keeper(store, "default")
315
+ collections = kp.list_collections()
316
+
317
+ if output_json:
318
+ typer.echo(json.dumps(collections))
319
+ else:
320
+ if not collections:
321
+ typer.echo("No collections.")
322
+ else:
323
+ for c in collections:
324
+ typer.echo(c)
325
+
326
+
327
+ @app.command()
328
+ def init(
329
+ store: StoreOption = None,
330
+ collection: CollectionOption = "default",
331
+ ):
332
+ """
333
+ Initialize or verify the store is ready.
334
+ """
335
+ kp = _get_keeper(store, collection)
336
+
337
+ # Show actual store path
338
+ actual_path = kp._store_path if hasattr(kp, '_store_path') else Path(store or ".keep")
339
+ typer.echo(f"✓ Store ready: {actual_path}")
340
+ typer.echo(f"✓ Collections: {kp.list_collections()}")
341
+
342
+ # Show detected providers
343
+ try:
344
+ if hasattr(kp, '_config'):
345
+ config = kp._config
346
+ typer.echo(f"\n✓ Detected providers:")
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")
355
+
356
+
357
+ @app.command("system")
358
+ def list_system(
359
+ store: StoreOption = None,
360
+ output_json: JsonOption = False,
361
+ ):
362
+ """
363
+ List all system documents (schema as data).
364
+ """
365
+ kp = _get_keeper(store, "default")
366
+ docs = kp.list_system_documents()
367
+ typer.echo(_format_items(docs, as_json=output_json))
368
+
369
+
370
+ @app.command("routing")
371
+ def show_routing(
372
+ store: StoreOption = None,
373
+ output_json: JsonOption = False,
374
+ ):
375
+ """
376
+ Show the current routing configuration.
377
+ """
378
+ kp = _get_keeper(store, "default")
379
+ routing = kp.get_routing()
380
+
381
+ if output_json:
382
+ from dataclasses import asdict
383
+ typer.echo(json.dumps(asdict(routing), indent=2))
384
+ else:
385
+ typer.echo(f"Summary: {routing.summary}")
386
+ typer.echo(f"Private patterns: {routing.private_patterns}")
387
+ typer.echo(f"Updated: {routing.updated}")
388
+
389
+
390
+ @app.command("process-pending")
391
+ def process_pending(
392
+ store: StoreOption = None,
393
+ limit: Annotated[int, typer.Option(
394
+ "--limit", "-n",
395
+ help="Maximum items to process in this batch"
396
+ )] = 10,
397
+ all_items: Annotated[bool, typer.Option(
398
+ "--all", "-a",
399
+ help="Process all pending items (ignores --limit)"
400
+ )] = False,
401
+ daemon: Annotated[bool, typer.Option(
402
+ "--daemon",
403
+ hidden=True,
404
+ help="Run as background daemon (used internally)"
405
+ )] = False,
406
+ output_json: JsonOption = False,
407
+ ):
408
+ """
409
+ Process pending summaries from lazy indexing.
410
+
411
+ Items indexed with --lazy use a truncated placeholder summary.
412
+ This command generates real summaries for those items.
413
+ """
414
+ kp = _get_keeper(store, "default")
415
+
416
+ # Daemon mode: write PID, process all, remove PID, exit silently
417
+ if daemon:
418
+ import signal
419
+
420
+ pid_path = kp._processor_pid_path
421
+ shutdown_requested = False
422
+
423
+ def handle_signal(signum, frame):
424
+ nonlocal shutdown_requested
425
+ shutdown_requested = True
426
+
427
+ # Handle common termination signals gracefully
428
+ signal.signal(signal.SIGTERM, handle_signal)
429
+ signal.signal(signal.SIGINT, handle_signal)
430
+
431
+ try:
432
+ # Write PID file
433
+ pid_path.write_text(str(os.getpid()))
434
+
435
+ # Process all items until queue empty or shutdown requested
436
+ while not shutdown_requested:
437
+ processed = kp.process_pending(limit=50)
438
+ if processed == 0:
439
+ break
440
+
441
+ finally:
442
+ # Clean up PID file
443
+ try:
444
+ pid_path.unlink()
445
+ except OSError:
446
+ pass
447
+ # Close resources
448
+ kp.close()
449
+ return
450
+
451
+ # Interactive mode
452
+ pending_before = kp.pending_count()
453
+
454
+ if pending_before == 0:
455
+ if output_json:
456
+ typer.echo(json.dumps({"processed": 0, "remaining": 0}))
457
+ else:
458
+ typer.echo("No pending summaries.")
459
+ return
460
+
461
+ if all_items:
462
+ # Process all items in batches
463
+ total_processed = 0
464
+ while True:
465
+ processed = kp.process_pending(limit=50)
466
+ total_processed += processed
467
+ if processed == 0:
468
+ break
469
+ if not output_json:
470
+ typer.echo(f" Processed {total_processed}...")
471
+
472
+ remaining = kp.pending_count()
473
+ if output_json:
474
+ typer.echo(json.dumps({
475
+ "processed": total_processed,
476
+ "remaining": remaining
477
+ }))
478
+ else:
479
+ typer.echo(f"✓ Processed {total_processed} items, {remaining} remaining")
480
+ else:
481
+ # Process limited batch
482
+ processed = kp.process_pending(limit=limit)
483
+ remaining = kp.pending_count()
484
+
485
+ if output_json:
486
+ typer.echo(json.dumps({
487
+ "processed": processed,
488
+ "remaining": remaining
489
+ }))
490
+ else:
491
+ typer.echo(f"✓ Processed {processed} items, {remaining} remaining")
492
+
493
+
494
+ # -----------------------------------------------------------------------------
495
+ # Entry point
496
+ # -----------------------------------------------------------------------------
497
+
498
+ def main():
499
+ app()
500
+
501
+
502
+ if __name__ == "__main__":
503
+ main()