keep-skill 0.1.0__py3-none-any.whl → 0.3.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 +1052 -145
- keep/cli.py +705 -132
- keep/config.py +172 -41
- keep/context.py +1 -125
- keep/document_store.py +908 -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 +52 -40
- keep/providers/embedding_cache.py +59 -46
- keep/providers/embeddings.py +43 -13
- keep/providers/mlx.py +23 -21
- keep/store.py +169 -25
- keep_skill-0.3.0.dist-info/METADATA +218 -0
- keep_skill-0.3.0.dist-info/RECORD +28 -0
- keep_skill-0.1.0.dist-info/METADATA +0 -290
- keep_skill-0.1.0.dist-info/RECORD +0 -26
- {keep_skill-0.1.0.dist-info → keep_skill-0.3.0.dist-info}/WHEEL +0 -0
- {keep_skill-0.1.0.dist-info → keep_skill-0.3.0.dist-info}/entry_points.txt +0 -0
- {keep_skill-0.1.0.dist-info → keep_skill-0.3.0.dist-info}/licenses/LICENSE +0 -0
keep/cli.py
CHANGED
|
@@ -16,24 +16,140 @@ from typing import Optional
|
|
|
16
16
|
import typer
|
|
17
17
|
from typing_extensions import Annotated
|
|
18
18
|
|
|
19
|
-
from .api import Keeper
|
|
19
|
+
from .api import Keeper, _text_content_id
|
|
20
|
+
from .document_store import VersionInfo
|
|
20
21
|
from .types import Item
|
|
21
|
-
from .logging_config import configure_quiet_mode
|
|
22
|
+
from .logging_config import configure_quiet_mode, enable_debug_mode
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
# Configure quiet mode by default (suppress verbose library output)
|
|
25
|
-
# Set KEEP_VERBOSE=1 to enable
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
# Set KEEP_VERBOSE=1 to enable debug mode via environment
|
|
27
|
+
if os.environ.get("KEEP_VERBOSE") == "1":
|
|
28
|
+
enable_debug_mode()
|
|
29
|
+
else:
|
|
30
|
+
configure_quiet_mode(quiet=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _verbose_callback(value: bool):
|
|
34
|
+
if value:
|
|
35
|
+
enable_debug_mode()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Global state for CLI options
|
|
39
|
+
_json_output = False
|
|
40
|
+
_ids_output = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _json_callback(value: bool):
|
|
44
|
+
global _json_output
|
|
45
|
+
_json_output = value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_json_output() -> bool:
|
|
49
|
+
return _json_output
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ids_callback(value: bool):
|
|
53
|
+
global _ids_output
|
|
54
|
+
_ids_output = value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_ids_output() -> bool:
|
|
58
|
+
return _ids_output
|
|
28
59
|
|
|
29
60
|
|
|
30
61
|
app = typer.Typer(
|
|
31
62
|
name="keep",
|
|
32
63
|
help="Associative memory with semantic search.",
|
|
33
|
-
no_args_is_help=
|
|
64
|
+
no_args_is_help=False,
|
|
65
|
+
invoke_without_command=True,
|
|
34
66
|
)
|
|
35
67
|
|
|
36
68
|
|
|
69
|
+
def _format_yaml_frontmatter(
|
|
70
|
+
item: Item,
|
|
71
|
+
version_nav: Optional[dict[str, list[VersionInfo]]] = None,
|
|
72
|
+
viewing_version: Optional[int] = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Format item as YAML frontmatter with summary as content.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
item: The item to format
|
|
79
|
+
version_nav: Optional version navigation info (prev/next lists)
|
|
80
|
+
viewing_version: If viewing an old version, the version number
|
|
81
|
+
"""
|
|
82
|
+
lines = ["---", f"id: {item.id}"]
|
|
83
|
+
if viewing_version is not None:
|
|
84
|
+
lines.append(f"version: {viewing_version}")
|
|
85
|
+
if item.tags:
|
|
86
|
+
lines.append("tags:")
|
|
87
|
+
for k, v in sorted(item.tags.items()):
|
|
88
|
+
lines.append(f" {k}: {v}")
|
|
89
|
+
if item.score is not None:
|
|
90
|
+
lines.append(f"score: {item.score:.3f}")
|
|
91
|
+
|
|
92
|
+
# Add version navigation if available
|
|
93
|
+
if version_nav:
|
|
94
|
+
if version_nav.get("prev"):
|
|
95
|
+
lines.append("prev:")
|
|
96
|
+
for v in version_nav["prev"]:
|
|
97
|
+
# Show version number, date portion, and truncated summary
|
|
98
|
+
date_part = v.created_at[:10] if v.created_at else "unknown"
|
|
99
|
+
summary_preview = v.summary[:40].replace("\n", " ")
|
|
100
|
+
if len(v.summary) > 40:
|
|
101
|
+
summary_preview += "..."
|
|
102
|
+
lines.append(f" - {v.version}: {date_part} {summary_preview}")
|
|
103
|
+
if version_nav.get("next"):
|
|
104
|
+
lines.append("next:")
|
|
105
|
+
for v in version_nav["next"]:
|
|
106
|
+
date_part = v.created_at[:10] if v.created_at else "unknown"
|
|
107
|
+
summary_preview = v.summary[:40].replace("\n", " ")
|
|
108
|
+
if len(v.summary) > 40:
|
|
109
|
+
summary_preview += "..."
|
|
110
|
+
lines.append(f" - {v.version}: {date_part} {summary_preview}")
|
|
111
|
+
elif viewing_version is not None:
|
|
112
|
+
# Viewing old version and next is empty means current is next
|
|
113
|
+
lines.append("next:")
|
|
114
|
+
lines.append(" - current")
|
|
115
|
+
|
|
116
|
+
lines.append("---")
|
|
117
|
+
lines.append(item.summary) # Summary IS the content
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.callback(invoke_without_command=True)
|
|
122
|
+
def main_callback(
|
|
123
|
+
ctx: typer.Context,
|
|
124
|
+
verbose: Annotated[bool, typer.Option(
|
|
125
|
+
"--verbose", "-v",
|
|
126
|
+
help="Enable debug-level logging to stderr",
|
|
127
|
+
callback=_verbose_callback,
|
|
128
|
+
is_eager=True,
|
|
129
|
+
)] = False,
|
|
130
|
+
output_json: Annotated[bool, typer.Option(
|
|
131
|
+
"--json", "-j",
|
|
132
|
+
help="Output as JSON",
|
|
133
|
+
callback=_json_callback,
|
|
134
|
+
is_eager=True,
|
|
135
|
+
)] = False,
|
|
136
|
+
ids_only: Annotated[bool, typer.Option(
|
|
137
|
+
"--ids", "-I",
|
|
138
|
+
help="Output only IDs (for piping to xargs)",
|
|
139
|
+
callback=_ids_callback,
|
|
140
|
+
is_eager=True,
|
|
141
|
+
)] = False,
|
|
142
|
+
):
|
|
143
|
+
"""Associative memory with semantic search."""
|
|
144
|
+
# If no subcommand provided, show the current context (now)
|
|
145
|
+
if ctx.invoked_subcommand is None:
|
|
146
|
+
kp = _get_keeper(None, "default")
|
|
147
|
+
item = kp.get_now()
|
|
148
|
+
typer.echo(_format_item(item, as_json=_get_json_output()))
|
|
149
|
+
if not _get_json_output():
|
|
150
|
+
typer.echo("\nUse --help for commands.", err=True)
|
|
151
|
+
|
|
152
|
+
|
|
37
153
|
# -----------------------------------------------------------------------------
|
|
38
154
|
# Common Options
|
|
39
155
|
# -----------------------------------------------------------------------------
|
|
@@ -63,11 +179,12 @@ LimitOption = Annotated[
|
|
|
63
179
|
)
|
|
64
180
|
]
|
|
65
181
|
|
|
66
|
-
|
|
67
|
-
|
|
182
|
+
|
|
183
|
+
SinceOption = Annotated[
|
|
184
|
+
Optional[str],
|
|
68
185
|
typer.Option(
|
|
69
|
-
"--
|
|
70
|
-
help="
|
|
186
|
+
"--since",
|
|
187
|
+
help="Only items updated since (ISO duration: P3D, P1W, PT1H; or date: 2026-01-15)"
|
|
71
188
|
)
|
|
72
189
|
]
|
|
73
190
|
|
|
@@ -76,20 +193,47 @@ JsonOption = Annotated[
|
|
|
76
193
|
# Output Helpers
|
|
77
194
|
# -----------------------------------------------------------------------------
|
|
78
195
|
|
|
79
|
-
def _format_item(
|
|
196
|
+
def _format_item(
|
|
197
|
+
item: Item,
|
|
198
|
+
as_json: bool = False,
|
|
199
|
+
version_nav: Optional[dict[str, list[VersionInfo]]] = None,
|
|
200
|
+
viewing_version: Optional[int] = None,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Format an item for display.
|
|
204
|
+
|
|
205
|
+
Text format: YAML frontmatter (matches docs/system format)
|
|
206
|
+
With --ids: just the ID (for piping)
|
|
207
|
+
"""
|
|
208
|
+
if _get_ids_output():
|
|
209
|
+
return json.dumps(item.id) if as_json else item.id
|
|
210
|
+
|
|
80
211
|
if as_json:
|
|
81
|
-
|
|
212
|
+
result = {
|
|
82
213
|
"id": item.id,
|
|
83
214
|
"summary": item.summary,
|
|
84
215
|
"tags": item.tags,
|
|
85
216
|
"score": item.score,
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
217
|
+
}
|
|
218
|
+
if viewing_version is not None:
|
|
219
|
+
result["version"] = viewing_version
|
|
220
|
+
if version_nav:
|
|
221
|
+
result["version_nav"] = {
|
|
222
|
+
k: [{"version": v.version, "created_at": v.created_at, "summary": v.summary[:60]}
|
|
223
|
+
for v in versions]
|
|
224
|
+
for k, versions in version_nav.items()
|
|
225
|
+
}
|
|
226
|
+
return json.dumps(result)
|
|
227
|
+
|
|
228
|
+
return _format_yaml_frontmatter(item, version_nav, viewing_version)
|
|
90
229
|
|
|
91
230
|
|
|
92
231
|
def _format_items(items: list[Item], as_json: bool = False) -> str:
|
|
232
|
+
"""Format multiple items for display."""
|
|
233
|
+
if _get_ids_output():
|
|
234
|
+
ids = [item.id for item in items]
|
|
235
|
+
return json.dumps(ids) if as_json else "\n".join(ids)
|
|
236
|
+
|
|
93
237
|
if as_json:
|
|
94
238
|
return json.dumps([
|
|
95
239
|
{
|
|
@@ -116,172 +260,548 @@ def _get_keeper(store: Optional[Path], collection: str) -> Keeper:
|
|
|
116
260
|
raise typer.Exit(1)
|
|
117
261
|
|
|
118
262
|
|
|
263
|
+
def _parse_tags(tags: Optional[list[str]]) -> dict[str, str]:
|
|
264
|
+
"""Parse key=value tag list to dict."""
|
|
265
|
+
if not tags:
|
|
266
|
+
return {}
|
|
267
|
+
parsed = {}
|
|
268
|
+
for tag in tags:
|
|
269
|
+
if "=" not in tag:
|
|
270
|
+
typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
k, v = tag.split("=", 1)
|
|
273
|
+
parsed[k] = v
|
|
274
|
+
return parsed
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _timestamp() -> str:
|
|
278
|
+
"""Generate timestamp for auto-generated IDs."""
|
|
279
|
+
from datetime import datetime, timezone
|
|
280
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parse_frontmatter(text: str) -> tuple[str, dict[str, str]]:
|
|
284
|
+
"""Parse YAML frontmatter from text, return (content, tags)."""
|
|
285
|
+
if text.startswith("---"):
|
|
286
|
+
parts = text.split("---", 2)
|
|
287
|
+
if len(parts) >= 3:
|
|
288
|
+
import yaml
|
|
289
|
+
frontmatter = yaml.safe_load(parts[1])
|
|
290
|
+
content = parts[2].lstrip("\n")
|
|
291
|
+
tags = frontmatter.get("tags", {}) if frontmatter else {}
|
|
292
|
+
return content, {k: str(v) for k, v in tags.items()}
|
|
293
|
+
return text, {}
|
|
294
|
+
|
|
295
|
+
|
|
119
296
|
# -----------------------------------------------------------------------------
|
|
120
297
|
# Commands
|
|
121
298
|
# -----------------------------------------------------------------------------
|
|
122
299
|
|
|
123
300
|
@app.command()
|
|
124
301
|
def find(
|
|
125
|
-
query: Annotated[str, typer.Argument(help="Search query text")],
|
|
302
|
+
query: Annotated[Optional[str], typer.Argument(help="Search query text")] = None,
|
|
303
|
+
id: Annotated[Optional[str], typer.Option(
|
|
304
|
+
"--id",
|
|
305
|
+
help="Find items similar to this ID (instead of text search)"
|
|
306
|
+
)] = None,
|
|
307
|
+
include_self: Annotated[bool, typer.Option(
|
|
308
|
+
help="Include the queried item (only with --id)"
|
|
309
|
+
)] = False,
|
|
126
310
|
store: StoreOption = None,
|
|
127
311
|
collection: CollectionOption = "default",
|
|
128
312
|
limit: LimitOption = 10,
|
|
129
|
-
|
|
313
|
+
since: SinceOption = None,
|
|
130
314
|
):
|
|
131
315
|
"""
|
|
132
316
|
Find items using semantic similarity search.
|
|
317
|
+
|
|
318
|
+
Examples:
|
|
319
|
+
keep find "authentication" # Search by text
|
|
320
|
+
keep find --id file:///path/to/doc.md # Find similar to item
|
|
133
321
|
"""
|
|
322
|
+
if id and query:
|
|
323
|
+
typer.echo("Error: Specify either a query or --id, not both", err=True)
|
|
324
|
+
raise typer.Exit(1)
|
|
325
|
+
if not id and not query:
|
|
326
|
+
typer.echo("Error: Specify a query or --id", err=True)
|
|
327
|
+
raise typer.Exit(1)
|
|
328
|
+
|
|
134
329
|
kp = _get_keeper(store, collection)
|
|
135
|
-
|
|
136
|
-
|
|
330
|
+
|
|
331
|
+
if id:
|
|
332
|
+
results = kp.find_similar(id, limit=limit, since=since, include_self=include_self)
|
|
333
|
+
else:
|
|
334
|
+
results = kp.find(query, limit=limit, since=since)
|
|
335
|
+
|
|
336
|
+
typer.echo(_format_items(results, as_json=_get_json_output()))
|
|
137
337
|
|
|
138
338
|
|
|
139
339
|
@app.command()
|
|
140
|
-
def
|
|
141
|
-
|
|
340
|
+
def search(
|
|
341
|
+
query: Annotated[str, typer.Argument(help="Full-text search query")],
|
|
142
342
|
store: StoreOption = None,
|
|
143
343
|
collection: CollectionOption = "default",
|
|
144
344
|
limit: LimitOption = 10,
|
|
145
|
-
|
|
146
|
-
output_json: JsonOption = False,
|
|
345
|
+
since: SinceOption = None,
|
|
147
346
|
):
|
|
148
347
|
"""
|
|
149
|
-
|
|
348
|
+
Search item summaries using full-text search.
|
|
150
349
|
"""
|
|
151
350
|
kp = _get_keeper(store, collection)
|
|
152
|
-
results = kp.
|
|
153
|
-
typer.echo(_format_items(results, as_json=
|
|
351
|
+
results = kp.query_fulltext(query, limit=limit, since=since)
|
|
352
|
+
typer.echo(_format_items(results, as_json=_get_json_output()))
|
|
154
353
|
|
|
155
354
|
|
|
156
|
-
@app.command()
|
|
157
|
-
def
|
|
158
|
-
query: Annotated[str, typer.Argument(help="Full-text search query")],
|
|
355
|
+
@app.command("list")
|
|
356
|
+
def list_recent(
|
|
159
357
|
store: StoreOption = None,
|
|
160
358
|
collection: CollectionOption = "default",
|
|
161
|
-
limit:
|
|
162
|
-
|
|
359
|
+
limit: Annotated[int, typer.Option(
|
|
360
|
+
"--limit", "-n",
|
|
361
|
+
help="Number of items to show"
|
|
362
|
+
)] = 10,
|
|
163
363
|
):
|
|
164
364
|
"""
|
|
165
|
-
|
|
365
|
+
List recent items by update time.
|
|
366
|
+
|
|
367
|
+
Shows the most recently updated items, newest first.
|
|
166
368
|
"""
|
|
167
369
|
kp = _get_keeper(store, collection)
|
|
168
|
-
results = kp.
|
|
169
|
-
typer.echo(_format_items(results, as_json=
|
|
370
|
+
results = kp.list_recent(limit=limit)
|
|
371
|
+
typer.echo(_format_items(results, as_json=_get_json_output()))
|
|
170
372
|
|
|
171
373
|
|
|
172
374
|
@app.command()
|
|
173
375
|
def tag(
|
|
174
|
-
|
|
175
|
-
|
|
376
|
+
query: Annotated[Optional[str], typer.Argument(
|
|
377
|
+
help="Tag key to list values, or key=value to find docs"
|
|
378
|
+
)] = None,
|
|
379
|
+
list_keys: Annotated[bool, typer.Option(
|
|
380
|
+
"--list", "-l",
|
|
381
|
+
help="List all distinct tag keys"
|
|
382
|
+
)] = False,
|
|
176
383
|
store: StoreOption = None,
|
|
177
384
|
collection: CollectionOption = "default",
|
|
178
385
|
limit: LimitOption = 100,
|
|
179
|
-
|
|
386
|
+
since: SinceOption = None,
|
|
180
387
|
):
|
|
181
388
|
"""
|
|
182
|
-
|
|
389
|
+
List tag values or find items by tag.
|
|
390
|
+
|
|
391
|
+
Examples:
|
|
392
|
+
keep tag --list # List all tag keys
|
|
393
|
+
keep tag project # List values for 'project' tag
|
|
394
|
+
keep tag project=myapp # Find docs with project=myapp
|
|
183
395
|
"""
|
|
184
396
|
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
397
|
|
|
398
|
+
# List all keys mode
|
|
399
|
+
if list_keys or query is None:
|
|
400
|
+
tags = kp.list_tags(None, collection=collection)
|
|
401
|
+
if _get_json_output():
|
|
402
|
+
typer.echo(json.dumps(tags))
|
|
403
|
+
else:
|
|
404
|
+
if not tags:
|
|
405
|
+
typer.echo("No tags found.")
|
|
406
|
+
else:
|
|
407
|
+
for t in tags:
|
|
408
|
+
typer.echo(t)
|
|
409
|
+
return
|
|
188
410
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
411
|
+
# Check if query is key=value or just key
|
|
412
|
+
if "=" in query:
|
|
413
|
+
# key=value → find documents
|
|
414
|
+
key, value = query.split("=", 1)
|
|
415
|
+
results = kp.query_tag(key, value, limit=limit, since=since)
|
|
416
|
+
typer.echo(_format_items(results, as_json=_get_json_output()))
|
|
417
|
+
else:
|
|
418
|
+
# key only → list values
|
|
419
|
+
values = kp.list_tags(query, collection=collection)
|
|
420
|
+
if _get_json_output():
|
|
421
|
+
typer.echo(json.dumps(values))
|
|
422
|
+
else:
|
|
423
|
+
if not values:
|
|
424
|
+
typer.echo(f"No values for tag '{query}'.")
|
|
425
|
+
else:
|
|
426
|
+
for v in values:
|
|
427
|
+
typer.echo(v)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@app.command("tag-update")
|
|
431
|
+
def tag_update(
|
|
432
|
+
ids: Annotated[list[str], typer.Argument(help="Document IDs to tag")],
|
|
194
433
|
tags: Annotated[Optional[list[str]], typer.Option(
|
|
195
434
|
"--tag", "-t",
|
|
196
|
-
help="
|
|
435
|
+
help="Tag as key=value (empty value removes: key=)"
|
|
197
436
|
)] = None,
|
|
198
|
-
|
|
199
|
-
"--
|
|
200
|
-
help="
|
|
201
|
-
)] =
|
|
202
|
-
|
|
437
|
+
remove: Annotated[Optional[list[str]], typer.Option(
|
|
438
|
+
"--remove", "-r",
|
|
439
|
+
help="Tag keys to remove"
|
|
440
|
+
)] = None,
|
|
441
|
+
store: StoreOption = None,
|
|
442
|
+
collection: CollectionOption = "default",
|
|
203
443
|
):
|
|
204
444
|
"""
|
|
205
|
-
Add or
|
|
445
|
+
Add, update, or remove tags on existing documents.
|
|
206
446
|
|
|
207
|
-
|
|
208
|
-
|
|
447
|
+
Does not re-process the document - only updates tags.
|
|
448
|
+
|
|
449
|
+
Examples:
|
|
450
|
+
keep tag-update doc:1 --tag project=myapp
|
|
451
|
+
keep tag-update doc:1 doc:2 --tag status=reviewed
|
|
452
|
+
keep tag-update doc:1 --remove obsolete_tag
|
|
453
|
+
keep tag-update doc:1 --tag temp= # Remove via empty value
|
|
209
454
|
"""
|
|
210
455
|
kp = _get_keeper(store, collection)
|
|
211
456
|
|
|
212
457
|
# Parse tags from key=value format
|
|
213
|
-
|
|
458
|
+
tag_changes: dict[str, str] = {}
|
|
214
459
|
if tags:
|
|
215
460
|
for tag in tags:
|
|
216
461
|
if "=" not in tag:
|
|
217
|
-
typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
|
|
462
|
+
typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value (or key= to remove)", err=True)
|
|
218
463
|
raise typer.Exit(1)
|
|
219
464
|
k, v = tag.split("=", 1)
|
|
220
|
-
|
|
465
|
+
tag_changes[k] = v # Empty v means delete
|
|
466
|
+
|
|
467
|
+
# Add explicit removals as empty strings
|
|
468
|
+
if remove:
|
|
469
|
+
for key in remove:
|
|
470
|
+
tag_changes[key] = ""
|
|
471
|
+
|
|
472
|
+
if not tag_changes:
|
|
473
|
+
typer.echo("Error: Specify at least one --tag or --remove", err=True)
|
|
474
|
+
raise typer.Exit(1)
|
|
221
475
|
|
|
222
|
-
|
|
223
|
-
|
|
476
|
+
# Process each document
|
|
477
|
+
results = []
|
|
478
|
+
for doc_id in ids:
|
|
479
|
+
item = kp.tag(doc_id, tags=tag_changes, collection=collection)
|
|
480
|
+
if item is None:
|
|
481
|
+
typer.echo(f"Not found: {doc_id}", err=True)
|
|
482
|
+
else:
|
|
483
|
+
results.append(item)
|
|
484
|
+
|
|
485
|
+
if _get_json_output():
|
|
486
|
+
typer.echo(_format_items(results, as_json=True))
|
|
487
|
+
else:
|
|
488
|
+
for item in results:
|
|
489
|
+
typer.echo(_format_item(item, as_json=False))
|
|
224
490
|
|
|
225
491
|
|
|
226
492
|
@app.command()
|
|
227
|
-
def
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
493
|
+
def update(
|
|
494
|
+
source: Annotated[Optional[str], typer.Argument(
|
|
495
|
+
help="URI to fetch, text content, or '-' for stdin"
|
|
496
|
+
)] = None,
|
|
231
497
|
id: Annotated[Optional[str], typer.Option(
|
|
232
498
|
"--id", "-i",
|
|
233
|
-
help="
|
|
499
|
+
help="Document ID (auto-generated for text/stdin modes)"
|
|
234
500
|
)] = None,
|
|
501
|
+
store: StoreOption = None,
|
|
502
|
+
collection: CollectionOption = "default",
|
|
235
503
|
tags: Annotated[Optional[list[str]], typer.Option(
|
|
236
504
|
"--tag", "-t",
|
|
237
|
-
help="
|
|
505
|
+
help="Tag as key=value (can be repeated)"
|
|
506
|
+
)] = None,
|
|
507
|
+
summary: Annotated[Optional[str], typer.Option(
|
|
508
|
+
"--summary",
|
|
509
|
+
help="User-provided summary (skips auto-summarization)"
|
|
238
510
|
)] = None,
|
|
239
511
|
lazy: Annotated[bool, typer.Option(
|
|
240
|
-
"--lazy",
|
|
512
|
+
"--lazy",
|
|
241
513
|
help="Fast mode: use truncated summary, queue for later processing"
|
|
242
514
|
)] = False,
|
|
243
|
-
output_json: JsonOption = False,
|
|
244
515
|
):
|
|
245
516
|
"""
|
|
246
|
-
|
|
517
|
+
Add or update a document in the store.
|
|
518
|
+
|
|
519
|
+
Three input modes (auto-detected):
|
|
520
|
+
keep update file:///path # URI mode: has ://
|
|
521
|
+
keep update "my note" # Text mode: content-addressed ID
|
|
522
|
+
keep update - # Stdin mode: explicit -
|
|
523
|
+
echo "pipe" | keep update # Stdin mode: piped input
|
|
247
524
|
|
|
248
|
-
|
|
249
|
-
|
|
525
|
+
Text mode uses content-addressed IDs for versioning:
|
|
526
|
+
keep update "my note" # Creates _text:{hash}
|
|
527
|
+
keep update "my note" -t done # Same ID, new version (tag change)
|
|
528
|
+
keep update "different note" # Different ID (new doc)
|
|
250
529
|
"""
|
|
251
530
|
kp = _get_keeper(store, collection)
|
|
531
|
+
parsed_tags = _parse_tags(tags)
|
|
532
|
+
|
|
533
|
+
# Determine mode based on source content
|
|
534
|
+
if source == "-" or (source is None and not sys.stdin.isatty()):
|
|
535
|
+
# Stdin mode: explicit '-' or piped input
|
|
536
|
+
content = sys.stdin.read()
|
|
537
|
+
content, frontmatter_tags = _parse_frontmatter(content)
|
|
538
|
+
parsed_tags = {**frontmatter_tags, **parsed_tags} # CLI tags override
|
|
539
|
+
# Use content-addressed ID for stdin text (enables versioning)
|
|
540
|
+
doc_id = id or _text_content_id(content)
|
|
541
|
+
item = kp.remember(content, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
|
|
542
|
+
elif source and "://" in source:
|
|
543
|
+
# URI mode: fetch from URI (ID is the URI itself)
|
|
544
|
+
item = kp.update(source, tags=parsed_tags or None, summary=summary, lazy=lazy)
|
|
545
|
+
elif source:
|
|
546
|
+
# Text mode: inline content (no :// in source)
|
|
547
|
+
# Use content-addressed ID for text (enables versioning)
|
|
548
|
+
doc_id = id or _text_content_id(source)
|
|
549
|
+
item = kp.remember(source, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
|
|
550
|
+
else:
|
|
551
|
+
typer.echo("Error: Provide content, URI, or '-' for stdin", err=True)
|
|
552
|
+
raise typer.Exit(1)
|
|
252
553
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
554
|
+
typer.echo(_format_item(item, as_json=_get_json_output()))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@app.command()
|
|
558
|
+
def now(
|
|
559
|
+
content: Annotated[Optional[str], typer.Argument(
|
|
560
|
+
help="Content to set (omit to show current)"
|
|
561
|
+
)] = None,
|
|
562
|
+
file: Annotated[Optional[Path], typer.Option(
|
|
563
|
+
"--file", "-f",
|
|
564
|
+
help="Read content from file"
|
|
565
|
+
)] = None,
|
|
566
|
+
reset: Annotated[bool, typer.Option(
|
|
567
|
+
"--reset",
|
|
568
|
+
help="Reset to default from system"
|
|
569
|
+
)] = False,
|
|
570
|
+
version: Annotated[Optional[int], typer.Option(
|
|
571
|
+
"--version", "-V",
|
|
572
|
+
help="Get specific version (0=current, 1=previous, etc.)"
|
|
573
|
+
)] = None,
|
|
574
|
+
history: Annotated[bool, typer.Option(
|
|
575
|
+
"--history", "-H",
|
|
576
|
+
help="List all versions"
|
|
577
|
+
)] = False,
|
|
578
|
+
store: StoreOption = None,
|
|
579
|
+
collection: CollectionOption = "default",
|
|
580
|
+
tags: Annotated[Optional[list[str]], typer.Option(
|
|
581
|
+
"--tag", "-t",
|
|
582
|
+
help="Tag as key=value (can be repeated)"
|
|
583
|
+
)] = None,
|
|
584
|
+
):
|
|
585
|
+
"""
|
|
586
|
+
Get or set the current working context.
|
|
587
|
+
|
|
588
|
+
With no arguments, displays the current context.
|
|
589
|
+
With content, replaces it.
|
|
590
|
+
|
|
591
|
+
Examples:
|
|
592
|
+
keep now # Show current context
|
|
593
|
+
keep now "What's important now" # Update context
|
|
594
|
+
keep now -f context.md # Read content from file
|
|
595
|
+
keep now --reset # Reset to default from system
|
|
596
|
+
keep now -V 1 # Previous version
|
|
597
|
+
keep now --history # List all versions
|
|
598
|
+
"""
|
|
599
|
+
from .api import NOWDOC_ID
|
|
262
600
|
|
|
263
|
-
|
|
264
|
-
|
|
601
|
+
kp = _get_keeper(store, collection)
|
|
602
|
+
|
|
603
|
+
# Handle history listing
|
|
604
|
+
if history:
|
|
605
|
+
versions = kp.list_versions(NOWDOC_ID, limit=50, collection=collection)
|
|
606
|
+
current = kp.get(NOWDOC_ID, collection=collection)
|
|
607
|
+
|
|
608
|
+
if _get_json_output():
|
|
609
|
+
result = {
|
|
610
|
+
"id": NOWDOC_ID,
|
|
611
|
+
"current": {
|
|
612
|
+
"summary": current.summary if current else None,
|
|
613
|
+
} if current else None,
|
|
614
|
+
"versions": [
|
|
615
|
+
{
|
|
616
|
+
"version": v.version,
|
|
617
|
+
"summary": v.summary[:60],
|
|
618
|
+
"created_at": v.created_at,
|
|
619
|
+
}
|
|
620
|
+
for v in versions
|
|
621
|
+
],
|
|
622
|
+
}
|
|
623
|
+
typer.echo(json.dumps(result, indent=2))
|
|
624
|
+
else:
|
|
625
|
+
if current:
|
|
626
|
+
summary_preview = current.summary[:60].replace("\n", " ")
|
|
627
|
+
typer.echo(f"Current: {summary_preview}...")
|
|
628
|
+
if versions:
|
|
629
|
+
typer.echo(f"\nVersion history ({len(versions)} archived):")
|
|
630
|
+
for v in versions:
|
|
631
|
+
date_part = v.created_at[:10] if v.created_at else "unknown"
|
|
632
|
+
summary_preview = v.summary[:50].replace("\n", " ")
|
|
633
|
+
if len(v.summary) > 50:
|
|
634
|
+
summary_preview += "..."
|
|
635
|
+
typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
|
|
636
|
+
else:
|
|
637
|
+
typer.echo("No version history.")
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
# Handle version retrieval
|
|
641
|
+
if version is not None:
|
|
642
|
+
offset = version
|
|
643
|
+
if offset == 0:
|
|
644
|
+
item = kp.get_now()
|
|
645
|
+
viewing_version = None
|
|
646
|
+
else:
|
|
647
|
+
item = kp.get_version(NOWDOC_ID, offset, collection=collection)
|
|
648
|
+
versions = kp.list_versions(NOWDOC_ID, limit=1, collection=collection)
|
|
649
|
+
if versions:
|
|
650
|
+
viewing_version = versions[0].version - (offset - 1)
|
|
651
|
+
else:
|
|
652
|
+
viewing_version = None
|
|
653
|
+
|
|
654
|
+
if item is None:
|
|
655
|
+
typer.echo(f"Version not found (offset {offset})", err=True)
|
|
656
|
+
raise typer.Exit(1)
|
|
657
|
+
|
|
658
|
+
version_nav = kp.get_version_nav(NOWDOC_ID, viewing_version, collection=collection)
|
|
659
|
+
typer.echo(_format_item(
|
|
660
|
+
item,
|
|
661
|
+
as_json=_get_json_output(),
|
|
662
|
+
version_nav=version_nav,
|
|
663
|
+
viewing_version=viewing_version,
|
|
664
|
+
))
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
# Determine if we're getting or setting
|
|
668
|
+
setting = content is not None or file is not None or reset
|
|
669
|
+
|
|
670
|
+
if setting:
|
|
671
|
+
if reset:
|
|
672
|
+
# Reset to default from system (delete first to clear old tags)
|
|
673
|
+
from .api import _load_frontmatter, SYSTEM_DOC_DIR
|
|
674
|
+
kp.delete(NOWDOC_ID)
|
|
675
|
+
try:
|
|
676
|
+
new_content, default_tags = _load_frontmatter(SYSTEM_DOC_DIR / "now.md")
|
|
677
|
+
parsed_tags = default_tags
|
|
678
|
+
except FileNotFoundError:
|
|
679
|
+
typer.echo("Error: Builtin now.md not found", err=True)
|
|
680
|
+
raise typer.Exit(1)
|
|
681
|
+
elif file is not None:
|
|
682
|
+
if not file.exists():
|
|
683
|
+
typer.echo(f"Error: File not found: {file}", err=True)
|
|
684
|
+
raise typer.Exit(1)
|
|
685
|
+
new_content = file.read_text()
|
|
686
|
+
parsed_tags = {}
|
|
687
|
+
else:
|
|
688
|
+
new_content = content
|
|
689
|
+
parsed_tags = {}
|
|
690
|
+
|
|
691
|
+
# Parse user-provided tags (merge with default if reset)
|
|
692
|
+
if tags:
|
|
693
|
+
for tag in tags:
|
|
694
|
+
if "=" not in tag:
|
|
695
|
+
typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
|
|
696
|
+
raise typer.Exit(1)
|
|
697
|
+
k, v = tag.split("=", 1)
|
|
698
|
+
parsed_tags[k] = v
|
|
699
|
+
|
|
700
|
+
item = kp.set_now(new_content, tags=parsed_tags or None)
|
|
701
|
+
typer.echo(_format_item(item, as_json=_get_json_output()))
|
|
702
|
+
else:
|
|
703
|
+
# Get current context with version navigation
|
|
704
|
+
item = kp.get_now()
|
|
705
|
+
version_nav = kp.get_version_nav(NOWDOC_ID, None, collection=collection)
|
|
706
|
+
typer.echo(_format_item(
|
|
707
|
+
item,
|
|
708
|
+
as_json=_get_json_output(),
|
|
709
|
+
version_nav=version_nav,
|
|
710
|
+
))
|
|
265
711
|
|
|
266
712
|
|
|
267
713
|
@app.command()
|
|
268
714
|
def get(
|
|
269
715
|
id: Annotated[str, typer.Argument(help="URI of item to retrieve")],
|
|
716
|
+
version: Annotated[Optional[int], typer.Option(
|
|
717
|
+
"--version", "-V",
|
|
718
|
+
help="Get specific version (0=current, 1=previous, etc.)"
|
|
719
|
+
)] = None,
|
|
720
|
+
history: Annotated[bool, typer.Option(
|
|
721
|
+
"--history", "-H",
|
|
722
|
+
help="List all versions"
|
|
723
|
+
)] = False,
|
|
270
724
|
store: StoreOption = None,
|
|
271
725
|
collection: CollectionOption = "default",
|
|
272
|
-
output_json: JsonOption = False,
|
|
273
726
|
):
|
|
274
727
|
"""
|
|
275
728
|
Retrieve a specific item by ID.
|
|
729
|
+
|
|
730
|
+
Examples:
|
|
731
|
+
keep get doc:1 # Current version with prev nav
|
|
732
|
+
keep get doc:1 -V 1 # Previous version with prev/next nav
|
|
733
|
+
keep get doc:1 --history # List all versions
|
|
276
734
|
"""
|
|
277
735
|
kp = _get_keeper(store, collection)
|
|
278
|
-
|
|
279
|
-
|
|
736
|
+
|
|
737
|
+
if history:
|
|
738
|
+
# List all versions
|
|
739
|
+
versions = kp.list_versions(id, limit=50, collection=collection)
|
|
740
|
+
current = kp.get(id, collection=collection)
|
|
741
|
+
|
|
742
|
+
if _get_json_output():
|
|
743
|
+
result = {
|
|
744
|
+
"id": id,
|
|
745
|
+
"current": {
|
|
746
|
+
"summary": current.summary if current else None,
|
|
747
|
+
"tags": current.tags if current else {},
|
|
748
|
+
} if current else None,
|
|
749
|
+
"versions": [
|
|
750
|
+
{
|
|
751
|
+
"version": v.version,
|
|
752
|
+
"summary": v.summary,
|
|
753
|
+
"created_at": v.created_at,
|
|
754
|
+
}
|
|
755
|
+
for v in versions
|
|
756
|
+
],
|
|
757
|
+
}
|
|
758
|
+
typer.echo(json.dumps(result, indent=2))
|
|
759
|
+
else:
|
|
760
|
+
if current:
|
|
761
|
+
typer.echo(f"Current: {current.summary[:60]}...")
|
|
762
|
+
if versions:
|
|
763
|
+
typer.echo(f"\nVersion history ({len(versions)} archived):")
|
|
764
|
+
for v in versions:
|
|
765
|
+
date_part = v.created_at[:10] if v.created_at else "unknown"
|
|
766
|
+
summary_preview = v.summary[:50].replace("\n", " ")
|
|
767
|
+
if len(v.summary) > 50:
|
|
768
|
+
summary_preview += "..."
|
|
769
|
+
typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
|
|
770
|
+
else:
|
|
771
|
+
typer.echo("No version history.")
|
|
772
|
+
return
|
|
773
|
+
|
|
774
|
+
# Get specific version or current
|
|
775
|
+
offset = version if version is not None else 0
|
|
776
|
+
|
|
777
|
+
if offset == 0:
|
|
778
|
+
item = kp.get(id, collection=collection)
|
|
779
|
+
viewing_version = None
|
|
780
|
+
else:
|
|
781
|
+
item = kp.get_version(id, offset, collection=collection)
|
|
782
|
+
# Calculate actual version number for display
|
|
783
|
+
versions = kp.list_versions(id, limit=1, collection=collection)
|
|
784
|
+
if versions:
|
|
785
|
+
viewing_version = versions[0].version - (offset - 1)
|
|
786
|
+
else:
|
|
787
|
+
viewing_version = None
|
|
788
|
+
|
|
280
789
|
if item is None:
|
|
281
|
-
|
|
790
|
+
if offset > 0:
|
|
791
|
+
typer.echo(f"Version not found: {id} (offset {offset})", err=True)
|
|
792
|
+
else:
|
|
793
|
+
typer.echo(f"Not found: {id}", err=True)
|
|
282
794
|
raise typer.Exit(1)
|
|
283
|
-
|
|
284
|
-
|
|
795
|
+
|
|
796
|
+
# Get version navigation
|
|
797
|
+
version_nav = kp.get_version_nav(id, viewing_version, collection=collection)
|
|
798
|
+
|
|
799
|
+
typer.echo(_format_item(
|
|
800
|
+
item,
|
|
801
|
+
as_json=_get_json_output(),
|
|
802
|
+
version_nav=version_nav,
|
|
803
|
+
viewing_version=viewing_version,
|
|
804
|
+
))
|
|
285
805
|
|
|
286
806
|
|
|
287
807
|
@app.command()
|
|
@@ -306,15 +826,14 @@ def exists(
|
|
|
306
826
|
@app.command("collections")
|
|
307
827
|
def list_collections(
|
|
308
828
|
store: StoreOption = None,
|
|
309
|
-
output_json: JsonOption = False,
|
|
310
829
|
):
|
|
311
830
|
"""
|
|
312
831
|
List all collections in the store.
|
|
313
832
|
"""
|
|
314
833
|
kp = _get_keeper(store, "default")
|
|
315
834
|
collections = kp.list_collections()
|
|
316
|
-
|
|
317
|
-
if
|
|
835
|
+
|
|
836
|
+
if _get_json_output():
|
|
318
837
|
typer.echo(json.dumps(collections))
|
|
319
838
|
else:
|
|
320
839
|
if not collections:
|
|
@@ -333,58 +852,101 @@ def init(
|
|
|
333
852
|
Initialize or verify the store is ready.
|
|
334
853
|
"""
|
|
335
854
|
kp = _get_keeper(store, collection)
|
|
336
|
-
|
|
337
|
-
# Show
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
855
|
+
|
|
856
|
+
# Show config and store paths
|
|
857
|
+
config = kp._config
|
|
858
|
+
config_path = config.config_path if config else None
|
|
859
|
+
store_path = kp._store_path
|
|
860
|
+
|
|
861
|
+
# Show paths
|
|
862
|
+
typer.echo(f"Config: {config_path}")
|
|
863
|
+
if config and config.config_dir and config.config_dir.resolve() != store_path.resolve():
|
|
864
|
+
typer.echo(f"Store: {store_path}")
|
|
865
|
+
|
|
866
|
+
typer.echo(f"Collections: {kp.list_collections()}")
|
|
867
|
+
|
|
342
868
|
# 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")
|
|
869
|
+
if config:
|
|
870
|
+
typer.echo(f"\nProviders:")
|
|
871
|
+
typer.echo(f" Embedding: {config.embedding.name}")
|
|
872
|
+
typer.echo(f" Summarization: {config.summarization.name}")
|
|
355
873
|
|
|
356
874
|
|
|
357
|
-
|
|
358
|
-
|
|
875
|
+
|
|
876
|
+
@app.command()
|
|
877
|
+
def config(
|
|
359
878
|
store: StoreOption = None,
|
|
360
|
-
output_json: JsonOption = False,
|
|
361
879
|
):
|
|
362
880
|
"""
|
|
363
|
-
|
|
881
|
+
Show current configuration and store location.
|
|
364
882
|
"""
|
|
365
883
|
kp = _get_keeper(store, "default")
|
|
366
|
-
|
|
367
|
-
|
|
884
|
+
|
|
885
|
+
cfg = kp._config
|
|
886
|
+
config_path = cfg.config_path if cfg else None
|
|
887
|
+
store_path = kp._store_path
|
|
888
|
+
|
|
889
|
+
if _get_json_output():
|
|
890
|
+
result = {
|
|
891
|
+
"store": str(store_path),
|
|
892
|
+
"config": str(config_path) if config_path else None,
|
|
893
|
+
"collections": kp.list_collections(),
|
|
894
|
+
}
|
|
895
|
+
if cfg:
|
|
896
|
+
result["embedding"] = cfg.embedding.name
|
|
897
|
+
result["summarization"] = cfg.summarization.name
|
|
898
|
+
typer.echo(json.dumps(result, indent=2))
|
|
899
|
+
else:
|
|
900
|
+
# Show paths
|
|
901
|
+
typer.echo(f"Config: {config_path}")
|
|
902
|
+
if cfg and cfg.config_dir and cfg.config_dir.resolve() != store_path.resolve():
|
|
903
|
+
typer.echo(f"Store: {store_path}")
|
|
904
|
+
|
|
905
|
+
typer.echo(f"Collections: {kp.list_collections()}")
|
|
906
|
+
|
|
907
|
+
if cfg:
|
|
908
|
+
typer.echo(f"\nProviders:")
|
|
909
|
+
typer.echo(f" Embedding: {cfg.embedding.name}")
|
|
910
|
+
typer.echo(f" Summarization: {cfg.summarization.name}")
|
|
368
911
|
|
|
369
912
|
|
|
370
|
-
@app.command("
|
|
371
|
-
def
|
|
913
|
+
@app.command("system")
|
|
914
|
+
def list_system(
|
|
372
915
|
store: StoreOption = None,
|
|
373
|
-
output_json: JsonOption = False,
|
|
374
916
|
):
|
|
375
917
|
"""
|
|
376
|
-
|
|
918
|
+
List the system documents.
|
|
919
|
+
|
|
920
|
+
Shows ID and summary for each. Use `keep get ID` for full details.
|
|
377
921
|
"""
|
|
378
922
|
kp = _get_keeper(store, "default")
|
|
379
|
-
|
|
923
|
+
docs = kp.list_system_documents()
|
|
380
924
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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))
|
|
384
940
|
else:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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}")
|
|
388
950
|
|
|
389
951
|
|
|
390
952
|
@app.command("process-pending")
|
|
@@ -403,7 +965,6 @@ def process_pending(
|
|
|
403
965
|
hidden=True,
|
|
404
966
|
help="Run as background daemon (used internally)"
|
|
405
967
|
)] = False,
|
|
406
|
-
output_json: JsonOption = False,
|
|
407
968
|
):
|
|
408
969
|
"""
|
|
409
970
|
Process pending summaries from lazy indexing.
|
|
@@ -452,7 +1013,7 @@ def process_pending(
|
|
|
452
1013
|
pending_before = kp.pending_count()
|
|
453
1014
|
|
|
454
1015
|
if pending_before == 0:
|
|
455
|
-
if
|
|
1016
|
+
if _get_json_output():
|
|
456
1017
|
typer.echo(json.dumps({"processed": 0, "remaining": 0}))
|
|
457
1018
|
else:
|
|
458
1019
|
typer.echo("No pending summaries.")
|
|
@@ -466,11 +1027,11 @@ def process_pending(
|
|
|
466
1027
|
total_processed += processed
|
|
467
1028
|
if processed == 0:
|
|
468
1029
|
break
|
|
469
|
-
if not
|
|
1030
|
+
if not _get_json_output():
|
|
470
1031
|
typer.echo(f" Processed {total_processed}...")
|
|
471
1032
|
|
|
472
1033
|
remaining = kp.pending_count()
|
|
473
|
-
if
|
|
1034
|
+
if _get_json_output():
|
|
474
1035
|
typer.echo(json.dumps({
|
|
475
1036
|
"processed": total_processed,
|
|
476
1037
|
"remaining": remaining
|
|
@@ -482,7 +1043,7 @@ def process_pending(
|
|
|
482
1043
|
processed = kp.process_pending(limit=limit)
|
|
483
1044
|
remaining = kp.pending_count()
|
|
484
1045
|
|
|
485
|
-
if
|
|
1046
|
+
if _get_json_output():
|
|
486
1047
|
typer.echo(json.dumps({
|
|
487
1048
|
"processed": processed,
|
|
488
1049
|
"remaining": remaining
|
|
@@ -496,7 +1057,19 @@ def process_pending(
|
|
|
496
1057
|
# -----------------------------------------------------------------------------
|
|
497
1058
|
|
|
498
1059
|
def main():
|
|
499
|
-
|
|
1060
|
+
try:
|
|
1061
|
+
app()
|
|
1062
|
+
except SystemExit:
|
|
1063
|
+
raise # Let typer handle exit codes
|
|
1064
|
+
except KeyboardInterrupt:
|
|
1065
|
+
raise SystemExit(130) # Standard exit code for Ctrl+C
|
|
1066
|
+
except Exception as e:
|
|
1067
|
+
# Log full traceback to file, show clean message to user
|
|
1068
|
+
from .errors import log_exception, ERROR_LOG_PATH
|
|
1069
|
+
log_exception(e, context="keep CLI")
|
|
1070
|
+
typer.echo(f"Error: {e}", err=True)
|
|
1071
|
+
typer.echo(f"Details logged to {ERROR_LOG_PATH}", err=True)
|
|
1072
|
+
raise SystemExit(1)
|
|
500
1073
|
|
|
501
1074
|
|
|
502
1075
|
if __name__ == "__main__":
|