keep-skill 0.4.1__tar.gz → 0.6.0__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.4.1 → keep_skill-0.6.0}/PKG-INFO +12 -8
- {keep_skill-0.4.1 → keep_skill-0.6.0}/README.md +11 -7
- {keep_skill-0.4.1 → keep_skill-0.6.0}/SKILL.md +21 -15
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/__init__.py +3 -2
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/api.py +193 -41
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/cli.py +134 -54
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/config.py +12 -7
- keep_skill-0.6.0/keep/data/__init__.py +1 -0
- keep_skill-0.6.0/keep/data/system/__init__.py +1 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/store.py +3 -9
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/types.py +4 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/pyproject.toml +1 -6
- {keep_skill-0.4.1 → keep_skill-0.6.0}/.gitignore +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/LICENSE +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/__main__.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/chunking.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/context.py +0 -0
- {keep_skill-0.4.1/docs → keep_skill-0.6.0/keep/data}/system/conversations.md +0 -0
- {keep_skill-0.4.1/docs → keep_skill-0.6.0/keep/data}/system/domains.md +0 -0
- {keep_skill-0.4.1/docs → keep_skill-0.6.0/keep/data}/system/now.md +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/document_store.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/errors.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/indexing.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/logging_config.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/paths.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/pending_summaries.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/providers/__init__.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/providers/base.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/providers/documents.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/providers/embedding_cache.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/providers/embeddings.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/providers/llm.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/providers/mlx.py +0 -0
- {keep_skill-0.4.1 → keep_skill-0.6.0}/keep/providers/summarization.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: keep-skill
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
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
|
|
@@ -56,7 +56,7 @@ Description-Content-Type: text/markdown
|
|
|
56
56
|
Index documents and notes. Search by meaning. Track changes over time.
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
|
-
|
|
59
|
+
uv tool install 'keep-skill[local]'
|
|
60
60
|
keep init
|
|
61
61
|
|
|
62
62
|
# Index content
|
|
@@ -90,16 +90,20 @@ Backed by ChromaDB for vectors, SQLite for metadata and versions.
|
|
|
90
90
|
**Python 3.11–3.13 required.**
|
|
91
91
|
|
|
92
92
|
```bash
|
|
93
|
-
# Recommended:
|
|
94
|
-
pip install 'keep-skill[local]'
|
|
95
|
-
|
|
96
|
-
# Or with uv (faster):
|
|
93
|
+
# Recommended: uv (isolated environment, fast)
|
|
97
94
|
uv tool install 'keep-skill[local]'
|
|
98
95
|
|
|
99
|
-
#
|
|
100
|
-
|
|
96
|
+
# Alternative: pip in a virtual environment
|
|
97
|
+
python -m venv .venv && source .venv/bin/activate
|
|
98
|
+
pip install 'keep-skill[local]'
|
|
99
|
+
|
|
100
|
+
# API-based (requires OPENAI_API_KEY)
|
|
101
|
+
uv tool install 'keep-skill[openai]'
|
|
101
102
|
```
|
|
102
103
|
|
|
104
|
+
> **Note:** Always use an isolated environment (uv or venv). Installing with system pip
|
|
105
|
+
> may cause version conflicts with dependencies like typer.
|
|
106
|
+
|
|
103
107
|
First run downloads embedding models (~3-5 minutes).
|
|
104
108
|
|
|
105
109
|
---
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Index documents and notes. Search by meaning. Track changes over time.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
8
|
+
uv tool install 'keep-skill[local]'
|
|
9
9
|
keep init
|
|
10
10
|
|
|
11
11
|
# Index content
|
|
@@ -39,16 +39,20 @@ Backed by ChromaDB for vectors, SQLite for metadata and versions.
|
|
|
39
39
|
**Python 3.11–3.13 required.**
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
# Recommended:
|
|
43
|
-
pip install 'keep-skill[local]'
|
|
44
|
-
|
|
45
|
-
# Or with uv (faster):
|
|
42
|
+
# Recommended: uv (isolated environment, fast)
|
|
46
43
|
uv tool install 'keep-skill[local]'
|
|
47
44
|
|
|
48
|
-
#
|
|
49
|
-
|
|
45
|
+
# Alternative: pip in a virtual environment
|
|
46
|
+
python -m venv .venv && source .venv/bin/activate
|
|
47
|
+
pip install 'keep-skill[local]'
|
|
48
|
+
|
|
49
|
+
# API-based (requires OPENAI_API_KEY)
|
|
50
|
+
uv tool install 'keep-skill[openai]'
|
|
50
51
|
```
|
|
51
52
|
|
|
53
|
+
> **Note:** Always use an isolated environment (uv or venv). Installing with system pip
|
|
54
|
+
> may cause version conflicts with dependencies like typer.
|
|
55
|
+
|
|
52
56
|
First run downloads embedding models (~3-5 minutes).
|
|
53
57
|
|
|
54
58
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: keep
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.6.0
|
|
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
|
|
@@ -79,10 +79,10 @@ This table is a summary. The full framework includes the Language-Action Perspec
|
|
|
79
79
|
|
|
80
80
|
**Do this now — read the full framework:**
|
|
81
81
|
```bash
|
|
82
|
-
keep get "
|
|
82
|
+
keep get "_system:conversations"
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
The
|
|
85
|
+
The conversations document is foundational. It teaches you to recognize where you are in work, what stance to take, and how to notice when things shift. Internalize it.
|
|
86
86
|
|
|
87
87
|
### The Triple Check
|
|
88
88
|
|
|
@@ -133,14 +133,14 @@ You've read the core patterns (Layer 2). There's more foundational material.
|
|
|
133
133
|
|
|
134
134
|
**Do this now — read and index:**
|
|
135
135
|
```bash
|
|
136
|
-
keep get "
|
|
136
|
+
keep get "_system:domains"
|
|
137
137
|
keep update "file://$PWD/docs/library/true_person_no_rank.md" -t type=teaching -t topic=commentary --lazy
|
|
138
138
|
keep get "file://$PWD/docs/library/true_person_no_rank.md"
|
|
139
139
|
```
|
|
140
140
|
|
|
141
141
|
These aren't test fixtures. They're seed wisdom:
|
|
142
142
|
|
|
143
|
-
-
|
|
143
|
+
- **`_system:domains`** — How to organize knowledge by domain (software, research, personal). Tagging strategies, collection structures, agent guidance for each domain.
|
|
144
144
|
|
|
145
145
|
- **[true_person_no_rank.md](docs/library/true_person_no_rank.md)** — A Zen teaching with layers of commentary. Notice how each commentator re-enacts the pointing from a different angle — not explaining, not accumulating facts, but each responding to what students in that era needed. This is how knowledge in your memory should relate: organic, not hierarchical.
|
|
146
146
|
|
|
@@ -260,7 +260,7 @@ Don't dump everything into context. Navigate the tree:
|
|
|
260
260
|
| `find` | Semantic similarity search | `keep find "authentication flow" --limit 5` |
|
|
261
261
|
| `find --id` | Find similar to existing item | `keep find --id "docid" --limit 3` |
|
|
262
262
|
| `search` | Full-text search in summaries | `keep search "OAuth"` |
|
|
263
|
-
| `list` | List recent
|
|
263
|
+
| `list` | List recent items | `keep list` or `keep --ids list` for IDs only |
|
|
264
264
|
| `update` | Index content (URI, text, or stdin) | `keep update "note" -t key=value` |
|
|
265
265
|
| `get` | Retrieve item (shows similar items) | `keep get "file:///path/to/doc.md"` |
|
|
266
266
|
| `get --similar` | List similar items | `keep get ID --similar` or `-n 20` for more |
|
|
@@ -291,17 +291,23 @@ The `--lazy` flag:
|
|
|
291
291
|
|
|
292
292
|
### Output
|
|
293
293
|
|
|
294
|
-
|
|
294
|
+
Three formats, consistent across all commands:
|
|
295
|
+
|
|
296
|
+
**Default: Summary lines** (one per item)
|
|
297
|
+
```
|
|
298
|
+
file:///doc.md@V{0} 2026-01-15 Document about authentication...
|
|
299
|
+
_text:a1b2c3d4@V{0} 2026-01-14 URI detection patterns...
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**With `--full`: YAML frontmatter** (`keep get` and `keep now` default to this)
|
|
295
303
|
```yaml
|
|
296
304
|
---
|
|
297
305
|
id: file:///path/to/doc.md
|
|
298
306
|
tags: {project: myapp, domain: auth}
|
|
299
307
|
similar:
|
|
300
|
-
- doc:related-auth (0.89)
|
|
301
|
-
- doc:token-notes (0.85)
|
|
302
|
-
score: 0.823
|
|
308
|
+
- doc:related-auth@V{0} (0.89) 2026-01-15 Related authentication...
|
|
303
309
|
prev:
|
|
304
|
-
-
|
|
310
|
+
- @V{1} 2026-01-14 Previous summary...
|
|
305
311
|
---
|
|
306
312
|
Document summary here...
|
|
307
313
|
```
|
|
@@ -310,7 +316,7 @@ Global flags (before the command):
|
|
|
310
316
|
```bash
|
|
311
317
|
keep --json find "auth" # JSON output
|
|
312
318
|
keep --ids find "auth" # IDs only (for piping)
|
|
313
|
-
keep --full list # Full
|
|
319
|
+
keep --full list # Full YAML frontmatter
|
|
314
320
|
keep -v find "auth" # Debug logging
|
|
315
321
|
```
|
|
316
322
|
|
|
@@ -318,9 +324,9 @@ keep -v find "auth" # Debug logging
|
|
|
318
324
|
|
|
319
325
|
Use `--ids` for Unix-style composition:
|
|
320
326
|
```bash
|
|
321
|
-
keep --ids system | xargs keep get # Get all system docs
|
|
322
327
|
keep --ids find "auth" | xargs keep get # Get full details of matches
|
|
323
328
|
keep --ids tag project=foo | xargs keep tag-update --tag status=done
|
|
329
|
+
keep --ids list | xargs -I{} keep get "{}" # Get details for recent items
|
|
324
330
|
```
|
|
325
331
|
|
|
326
332
|
### Store Location
|
|
@@ -361,5 +367,5 @@ This is the practice. Not once, but every time.
|
|
|
361
367
|
- [docs/AGENT-GUIDE.md](docs/AGENT-GUIDE.md) — Detailed patterns for working sessions
|
|
362
368
|
- [docs/REFERENCE.md](docs/REFERENCE.md) — Complete CLI and API reference
|
|
363
369
|
- [docs/QUICKSTART.md](docs/QUICKSTART.md) — Installation and setup
|
|
364
|
-
- [
|
|
365
|
-
- [
|
|
370
|
+
- [keep/data/system/conversations.md](keep/data/system/conversations.md) — Full conversation framework (`_system:conversations`)
|
|
371
|
+
- [keep/data/system/domains.md](keep/data/system/domains.md) — Domain-specific organization (`_system:domains`)
|
|
@@ -38,13 +38,14 @@ if not os.environ.get("KEEP_VERBOSE"):
|
|
|
38
38
|
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
|
39
39
|
|
|
40
40
|
from .api import Keeper, NOWDOC_ID
|
|
41
|
-
from .types import Item, filter_non_system_tags, SYSTEM_TAG_PREFIX
|
|
41
|
+
from .types import Item, filter_non_system_tags, SYSTEM_TAG_PREFIX, INTERNAL_TAGS
|
|
42
42
|
|
|
43
|
-
__version__ = "0.
|
|
43
|
+
__version__ = "0.6.0"
|
|
44
44
|
__all__ = [
|
|
45
45
|
"Keeper",
|
|
46
46
|
"Item",
|
|
47
47
|
"NOWDOC_ID",
|
|
48
48
|
"filter_non_system_tags",
|
|
49
49
|
"SYSTEM_TAG_PREFIX",
|
|
50
|
+
"INTERNAL_TAGS",
|
|
50
51
|
]
|
|
@@ -9,6 +9,7 @@ This is the minimal working implementation focused on:
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import hashlib
|
|
12
|
+
import importlib.resources
|
|
12
13
|
import logging
|
|
13
14
|
import re
|
|
14
15
|
from datetime import datetime, timezone, timedelta
|
|
@@ -100,6 +101,24 @@ def _filter_by_date(items: list, since: str) -> list:
|
|
|
100
101
|
if item.tags.get("_updated_date", "0000-00-00") >= cutoff
|
|
101
102
|
]
|
|
102
103
|
|
|
104
|
+
|
|
105
|
+
def _record_to_item(rec, score: float = None) -> "Item":
|
|
106
|
+
"""
|
|
107
|
+
Convert a DocumentRecord to an Item with timestamp tags.
|
|
108
|
+
|
|
109
|
+
Adds _updated, _created, _updated_date from the record's columns
|
|
110
|
+
to ensure consistent timestamp exposure across all retrieval methods.
|
|
111
|
+
"""
|
|
112
|
+
from .types import Item
|
|
113
|
+
tags = {
|
|
114
|
+
**rec.tags,
|
|
115
|
+
"_updated": rec.updated_at,
|
|
116
|
+
"_created": rec.created_at,
|
|
117
|
+
"_updated_date": rec.updated_at[:10] if rec.updated_at else "",
|
|
118
|
+
}
|
|
119
|
+
return Item(id=rec.id, summary=rec.summary, tags=tags, score=score)
|
|
120
|
+
|
|
121
|
+
|
|
103
122
|
import os
|
|
104
123
|
import subprocess
|
|
105
124
|
import sys
|
|
@@ -135,8 +154,44 @@ ENV_TAG_PREFIX = "KEEP_TAG_"
|
|
|
135
154
|
# Fixed ID for the current working context (singleton)
|
|
136
155
|
NOWDOC_ID = "_now:default"
|
|
137
156
|
|
|
157
|
+
|
|
158
|
+
def _get_system_doc_dir() -> Path:
|
|
159
|
+
"""
|
|
160
|
+
Get path to system docs, works in both dev and installed environments.
|
|
161
|
+
|
|
162
|
+
Tries in order:
|
|
163
|
+
1. Package data via importlib.resources (installed packages)
|
|
164
|
+
2. Relative path inside package (development)
|
|
165
|
+
3. Legacy path outside package (backwards compatibility)
|
|
166
|
+
"""
|
|
167
|
+
# Try package data first (works for installed packages)
|
|
168
|
+
try:
|
|
169
|
+
with importlib.resources.as_file(
|
|
170
|
+
importlib.resources.files("keep.data.system")
|
|
171
|
+
) as path:
|
|
172
|
+
if path.exists():
|
|
173
|
+
return path
|
|
174
|
+
except (ModuleNotFoundError, TypeError):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
# Fallback to relative path inside package (development)
|
|
178
|
+
dev_path = Path(__file__).parent / "data" / "system"
|
|
179
|
+
if dev_path.exists():
|
|
180
|
+
return dev_path
|
|
181
|
+
|
|
182
|
+
# Legacy fallback (old structure)
|
|
183
|
+
return Path(__file__).parent.parent / "docs" / "system"
|
|
184
|
+
|
|
185
|
+
|
|
138
186
|
# Path to system documents
|
|
139
|
-
SYSTEM_DOC_DIR =
|
|
187
|
+
SYSTEM_DOC_DIR = _get_system_doc_dir()
|
|
188
|
+
|
|
189
|
+
# Stable IDs for system documents (path-independent)
|
|
190
|
+
SYSTEM_DOC_IDS = {
|
|
191
|
+
"now.md": "_system:now",
|
|
192
|
+
"conversations.md": "_system:conversations",
|
|
193
|
+
"domains.md": "_system:domains",
|
|
194
|
+
}
|
|
140
195
|
|
|
141
196
|
|
|
142
197
|
def _load_frontmatter(path: Path) -> tuple[str, dict[str, str]]:
|
|
@@ -295,32 +350,88 @@ class Keeper:
|
|
|
295
350
|
embedding_dimension=embedding_dim,
|
|
296
351
|
)
|
|
297
352
|
|
|
298
|
-
#
|
|
299
|
-
self.
|
|
353
|
+
# Migrate and ensure system documents (idempotent)
|
|
354
|
+
self._migrate_system_documents()
|
|
300
355
|
|
|
301
|
-
def
|
|
356
|
+
def _migrate_system_documents(self) -> dict:
|
|
302
357
|
"""
|
|
303
|
-
|
|
358
|
+
Migrate system documents to stable IDs and current version.
|
|
304
359
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
360
|
+
Handles:
|
|
361
|
+
- Migration from old file:// URIs to _system:{name} IDs
|
|
362
|
+
- Fresh creation for new stores
|
|
363
|
+
- Version upgrades when bundled content changes
|
|
364
|
+
- Cleanup of old file:// URIs (from before path was changed)
|
|
308
365
|
|
|
309
366
|
Called during init. Only loads docs that don't already exist,
|
|
310
|
-
so user modifications are preserved
|
|
311
|
-
|
|
367
|
+
so user modifications are preserved. Updates config version
|
|
368
|
+
after successful migration.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Dict with migration stats: created, migrated, skipped, cleaned
|
|
312
372
|
"""
|
|
373
|
+
from .config import SYSTEM_DOCS_VERSION, save_config
|
|
374
|
+
|
|
375
|
+
stats = {"created": 0, "migrated": 0, "skipped": 0, "cleaned": 0}
|
|
376
|
+
|
|
377
|
+
# Skip if already at current version
|
|
378
|
+
if self._config.system_docs_version >= SYSTEM_DOCS_VERSION:
|
|
379
|
+
return stats
|
|
380
|
+
|
|
381
|
+
# Build reverse lookup: filename -> new stable ID
|
|
382
|
+
filename_to_id = {name: doc_id for name, doc_id in SYSTEM_DOC_IDS.items()}
|
|
383
|
+
|
|
384
|
+
# First pass: clean up old file:// URIs with category=system tag
|
|
385
|
+
# These may have different paths than current SYSTEM_DOC_DIR
|
|
386
|
+
try:
|
|
387
|
+
old_system_docs = self.query_tag("category", "system")
|
|
388
|
+
for doc in old_system_docs:
|
|
389
|
+
if doc.id.startswith("file://") and doc.id.endswith(".md"):
|
|
390
|
+
# Extract filename from path
|
|
391
|
+
filename = Path(doc.id.replace("file://", "")).name
|
|
392
|
+
new_id = filename_to_id.get(filename)
|
|
393
|
+
if new_id and not self.exists(new_id):
|
|
394
|
+
# Migrate content to new ID
|
|
395
|
+
self.remember(doc.summary, id=new_id, tags=doc.tags)
|
|
396
|
+
self.delete(doc.id)
|
|
397
|
+
stats["migrated"] += 1
|
|
398
|
+
logger.info("Migrated system doc: %s -> %s", doc.id, new_id)
|
|
399
|
+
elif new_id:
|
|
400
|
+
# New ID already exists, just clean up old one
|
|
401
|
+
self.delete(doc.id)
|
|
402
|
+
stats["cleaned"] += 1
|
|
403
|
+
logger.info("Cleaned up old system doc: %s", doc.id)
|
|
404
|
+
except Exception as e:
|
|
405
|
+
logger.debug("Error scanning old system docs: %s", e)
|
|
406
|
+
|
|
407
|
+
# Second pass: create any missing system docs from bundled content
|
|
313
408
|
for path in SYSTEM_DOC_DIR.glob("*.md"):
|
|
409
|
+
new_id = SYSTEM_DOC_IDS.get(path.name)
|
|
410
|
+
if new_id is None:
|
|
411
|
+
logger.debug("Skipping unknown system doc: %s", path.name)
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
# Skip if already exists
|
|
415
|
+
if self.exists(new_id):
|
|
416
|
+
stats["skipped"] += 1
|
|
417
|
+
continue
|
|
418
|
+
|
|
314
419
|
try:
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
420
|
+
content, tags = _load_frontmatter(path)
|
|
421
|
+
tags["category"] = "system"
|
|
422
|
+
self.remember(content, id=new_id, tags=tags)
|
|
423
|
+
stats["created"] += 1
|
|
424
|
+
logger.info("Created system doc: %s", new_id)
|
|
320
425
|
except FileNotFoundError:
|
|
321
426
|
# System file missing - skip silently
|
|
322
427
|
pass
|
|
323
428
|
|
|
429
|
+
# Update config version
|
|
430
|
+
self._config.system_docs_version = SYSTEM_DOCS_VERSION
|
|
431
|
+
save_config(self._config)
|
|
432
|
+
|
|
433
|
+
return stats
|
|
434
|
+
|
|
324
435
|
def _get_embedding_provider(self) -> EmbeddingProvider:
|
|
325
436
|
"""
|
|
326
437
|
Get embedding provider, creating it lazily on first use.
|
|
@@ -581,12 +692,8 @@ class Keeper:
|
|
|
581
692
|
|
|
582
693
|
# Return the stored item
|
|
583
694
|
doc_record = self._document_store.get(coll, id)
|
|
584
|
-
return
|
|
585
|
-
|
|
586
|
-
summary=doc_record.summary,
|
|
587
|
-
tags=doc_record.tags,
|
|
588
|
-
)
|
|
589
|
-
|
|
695
|
+
return _record_to_item(doc_record)
|
|
696
|
+
|
|
590
697
|
def remember(
|
|
591
698
|
self,
|
|
592
699
|
content: str,
|
|
@@ -759,11 +866,7 @@ class Keeper:
|
|
|
759
866
|
|
|
760
867
|
# Return the stored item
|
|
761
868
|
doc_record = self._document_store.get(coll, id)
|
|
762
|
-
return
|
|
763
|
-
id=doc_record.id,
|
|
764
|
-
summary=doc_record.summary,
|
|
765
|
-
tags=doc_record.tags,
|
|
766
|
-
)
|
|
869
|
+
return _record_to_item(doc_record)
|
|
767
870
|
|
|
768
871
|
# -------------------------------------------------------------------------
|
|
769
872
|
# Query Operations
|
|
@@ -962,6 +1065,28 @@ class Keeper:
|
|
|
962
1065
|
|
|
963
1066
|
return filtered
|
|
964
1067
|
|
|
1068
|
+
def get_version_offset(self, item: Item, collection: Optional[str] = None) -> int:
|
|
1069
|
+
"""
|
|
1070
|
+
Get version offset (0=current, 1=previous, ...) for an item.
|
|
1071
|
+
|
|
1072
|
+
Converts the internal version number (1=oldest, 2=next...) to the
|
|
1073
|
+
user-visible offset format (0=current, 1=previous, 2=two-ago...).
|
|
1074
|
+
|
|
1075
|
+
Args:
|
|
1076
|
+
item: Item to get version offset for
|
|
1077
|
+
collection: Target collection
|
|
1078
|
+
|
|
1079
|
+
Returns:
|
|
1080
|
+
Version offset (0 for current version)
|
|
1081
|
+
"""
|
|
1082
|
+
version_tag = item.tags.get("_version")
|
|
1083
|
+
if not version_tag:
|
|
1084
|
+
return 0 # Current version
|
|
1085
|
+
base_id = item.tags.get("_base_id", item.id)
|
|
1086
|
+
coll = self._resolve_collection(collection)
|
|
1087
|
+
version_count = self._document_store.version_count(coll, base_id)
|
|
1088
|
+
return version_count - int(version_tag) + 1
|
|
1089
|
+
|
|
965
1090
|
def query_fulltext(
|
|
966
1091
|
self,
|
|
967
1092
|
query: str,
|
|
@@ -1033,7 +1158,7 @@ class Keeper:
|
|
|
1033
1158
|
docs = self._document_store.query_by_tag_key(
|
|
1034
1159
|
coll, key, limit=limit, since_date=since_date
|
|
1035
1160
|
)
|
|
1036
|
-
return [
|
|
1161
|
+
return [_record_to_item(d) for d in docs]
|
|
1037
1162
|
|
|
1038
1163
|
# Build tag filter from positional or keyword args
|
|
1039
1164
|
tag_filter = {}
|
|
@@ -1107,11 +1232,7 @@ class Keeper:
|
|
|
1107
1232
|
# Try document store first (canonical)
|
|
1108
1233
|
doc_record = self._document_store.get(coll, id)
|
|
1109
1234
|
if doc_record:
|
|
1110
|
-
return
|
|
1111
|
-
id=doc_record.id,
|
|
1112
|
-
summary=doc_record.summary,
|
|
1113
|
-
tags=doc_record.tags,
|
|
1114
|
-
)
|
|
1235
|
+
return _record_to_item(doc_record)
|
|
1115
1236
|
|
|
1116
1237
|
# Fall back to ChromaDB for legacy data
|
|
1117
1238
|
result = self._store.get(coll, id)
|
|
@@ -1249,7 +1370,7 @@ class Keeper:
|
|
|
1249
1370
|
|
|
1250
1371
|
A singleton document representing what you're currently working on.
|
|
1251
1372
|
If it doesn't exist, creates one with default content and tags from
|
|
1252
|
-
|
|
1373
|
+
the bundled system now.md file.
|
|
1253
1374
|
|
|
1254
1375
|
Returns:
|
|
1255
1376
|
The current context Item (never None - auto-creates if missing)
|
|
@@ -1306,6 +1427,44 @@ class Keeper:
|
|
|
1306
1427
|
"""
|
|
1307
1428
|
return self.query_tag("category", "system", collection=collection)
|
|
1308
1429
|
|
|
1430
|
+
def reset_system_documents(self) -> dict:
|
|
1431
|
+
"""
|
|
1432
|
+
Force reload all system documents from bundled content.
|
|
1433
|
+
|
|
1434
|
+
This overwrites any user modifications to system documents.
|
|
1435
|
+
Use with caution - primarily for recovery or testing.
|
|
1436
|
+
|
|
1437
|
+
Returns:
|
|
1438
|
+
Dict with stats: reset count
|
|
1439
|
+
"""
|
|
1440
|
+
from .config import SYSTEM_DOCS_VERSION, save_config
|
|
1441
|
+
|
|
1442
|
+
stats = {"reset": 0}
|
|
1443
|
+
|
|
1444
|
+
for path in SYSTEM_DOC_DIR.glob("*.md"):
|
|
1445
|
+
new_id = SYSTEM_DOC_IDS.get(path.name)
|
|
1446
|
+
if new_id is None:
|
|
1447
|
+
continue
|
|
1448
|
+
|
|
1449
|
+
try:
|
|
1450
|
+
content, tags = _load_frontmatter(path)
|
|
1451
|
+
tags["category"] = "system"
|
|
1452
|
+
|
|
1453
|
+
# Delete existing (if any) and create fresh
|
|
1454
|
+
self.delete(new_id)
|
|
1455
|
+
self.remember(content, id=new_id, tags=tags)
|
|
1456
|
+
stats["reset"] += 1
|
|
1457
|
+
logger.info("Reset system doc: %s", new_id)
|
|
1458
|
+
|
|
1459
|
+
except FileNotFoundError:
|
|
1460
|
+
logger.warning("System doc file not found: %s", path)
|
|
1461
|
+
|
|
1462
|
+
# Update config version
|
|
1463
|
+
self._config.system_docs_version = SYSTEM_DOCS_VERSION
|
|
1464
|
+
save_config(self._config)
|
|
1465
|
+
|
|
1466
|
+
return stats
|
|
1467
|
+
|
|
1309
1468
|
def tag(
|
|
1310
1469
|
self,
|
|
1311
1470
|
id: str,
|
|
@@ -1410,14 +1569,7 @@ class Keeper:
|
|
|
1410
1569
|
coll = self._resolve_collection(collection)
|
|
1411
1570
|
records = self._document_store.list_recent(coll, limit)
|
|
1412
1571
|
|
|
1413
|
-
return [
|
|
1414
|
-
Item(
|
|
1415
|
-
id=rec.id,
|
|
1416
|
-
summary=rec.summary,
|
|
1417
|
-
tags=rec.tags,
|
|
1418
|
-
score=None,
|
|
1419
|
-
)
|
|
1420
|
-
for rec in records
|
|
1572
|
+
return [_record_to_item(rec) for rec in records
|
|
1421
1573
|
]
|
|
1422
1574
|
|
|
1423
1575
|
def embedding_cache_stats(self) -> dict:
|
|
@@ -17,10 +17,13 @@ from typing import Optional
|
|
|
17
17
|
import typer
|
|
18
18
|
from typing_extensions import Annotated
|
|
19
19
|
|
|
20
|
-
|
|
21
20
|
# Pattern for version identifier suffix: @V{N} where N is digits only
|
|
22
21
|
VERSION_SUFFIX_PATTERN = re.compile(r'@V\{(\d+)\}$')
|
|
23
22
|
|
|
23
|
+
# URI scheme pattern per RFC 3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
|
|
24
|
+
# Used to distinguish URIs from plain text in the update command
|
|
25
|
+
_URI_SCHEME_PATTERN = re.compile(r'^[a-zA-Z][a-zA-Z0-9+.-]*://')
|
|
26
|
+
|
|
24
27
|
from .api import Keeper, _text_content_id
|
|
25
28
|
from .document_store import VersionInfo
|
|
26
29
|
from .types import Item
|
|
@@ -82,11 +85,29 @@ app = typer.Typer(
|
|
|
82
85
|
)
|
|
83
86
|
|
|
84
87
|
|
|
88
|
+
# -----------------------------------------------------------------------------
|
|
89
|
+
# Output Formatting
|
|
90
|
+
#
|
|
91
|
+
# Three output formats, controlled by global flags:
|
|
92
|
+
# --ids: versioned ID only (id@V{N})
|
|
93
|
+
# --full: YAML frontmatter with tags, similar items, version nav
|
|
94
|
+
# default: summary line (id@V{N} date summary)
|
|
95
|
+
#
|
|
96
|
+
# JSON output (--json) works with any of the above.
|
|
97
|
+
# -----------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _filter_display_tags(tags: dict) -> dict:
|
|
100
|
+
"""Filter out internal-only tags for display."""
|
|
101
|
+
from .types import INTERNAL_TAGS
|
|
102
|
+
return {k: v for k, v in tags.items() if k not in INTERNAL_TAGS}
|
|
103
|
+
|
|
104
|
+
|
|
85
105
|
def _format_yaml_frontmatter(
|
|
86
106
|
item: Item,
|
|
87
107
|
version_nav: Optional[dict[str, list[VersionInfo]]] = None,
|
|
88
108
|
viewing_offset: Optional[int] = None,
|
|
89
109
|
similar_items: Optional[list[Item]] = None,
|
|
110
|
+
similar_offsets: Optional[dict[str, int]] = None,
|
|
90
111
|
) -> str:
|
|
91
112
|
"""
|
|
92
113
|
Format item as YAML frontmatter with summary as content.
|
|
@@ -96,6 +117,7 @@ def _format_yaml_frontmatter(
|
|
|
96
117
|
version_nav: Optional version navigation info (prev/next lists)
|
|
97
118
|
viewing_offset: If viewing an old version, the offset (1=previous, 2=two ago)
|
|
98
119
|
similar_items: Optional list of similar items to display
|
|
120
|
+
similar_offsets: Version offsets for similar items (item.id -> offset)
|
|
99
121
|
|
|
100
122
|
Note: Offset computation (v1, v2, etc.) assumes version_nav lists
|
|
101
123
|
are ordered newest-first, matching list_versions() ordering.
|
|
@@ -104,20 +126,27 @@ def _format_yaml_frontmatter(
|
|
|
104
126
|
lines = ["---", f"id: {item.id}"]
|
|
105
127
|
if viewing_offset is not None:
|
|
106
128
|
lines.append(f"version: {viewing_offset}")
|
|
107
|
-
|
|
108
|
-
|
|
129
|
+
display_tags = _filter_display_tags(item.tags)
|
|
130
|
+
if display_tags:
|
|
131
|
+
tag_items = ", ".join(f"{k}: {v}" for k, v in sorted(display_tags.items()))
|
|
109
132
|
lines.append(f"tags: {{{tag_items}}}")
|
|
110
133
|
if item.score is not None:
|
|
111
134
|
lines.append(f"score: {item.score:.3f}")
|
|
112
135
|
|
|
113
|
-
# Add similar items if available
|
|
136
|
+
# Add similar items if available (version-scoped IDs with date and summary)
|
|
114
137
|
if similar_items:
|
|
115
138
|
lines.append("similar:")
|
|
116
139
|
for sim_item in similar_items:
|
|
140
|
+
base_id = sim_item.tags.get("_base_id", sim_item.id)
|
|
141
|
+
offset = (similar_offsets or {}).get(sim_item.id, 0)
|
|
117
142
|
score_str = f"({sim_item.score:.2f})" if sim_item.score else ""
|
|
118
|
-
|
|
143
|
+
date_part = sim_item.tags.get("_updated", sim_item.tags.get("_created", ""))[:10]
|
|
144
|
+
summary_preview = sim_item.summary[:40].replace("\n", " ")
|
|
145
|
+
if len(sim_item.summary) > 40:
|
|
146
|
+
summary_preview += "..."
|
|
147
|
+
lines.append(f" - {base_id}@V{{{offset}}} {score_str} {date_part} {summary_preview}")
|
|
119
148
|
|
|
120
|
-
# Add version navigation
|
|
149
|
+
# Add version navigation (just @V{N} since ID is shown at top, with date + summary)
|
|
121
150
|
if version_nav:
|
|
122
151
|
# Current offset (0 if viewing current)
|
|
123
152
|
current_offset = viewing_offset if viewing_offset is not None else 0
|
|
@@ -125,33 +154,56 @@ def _format_yaml_frontmatter(
|
|
|
125
154
|
if version_nav.get("prev"):
|
|
126
155
|
lines.append("prev:")
|
|
127
156
|
for i, v in enumerate(version_nav["prev"]):
|
|
128
|
-
# Offset for this prev item: current_offset + i + 1
|
|
129
157
|
prev_offset = current_offset + i + 1
|
|
130
|
-
date_part = v.created_at[:10] if v.created_at else "
|
|
158
|
+
date_part = v.created_at[:10] if v.created_at else ""
|
|
131
159
|
summary_preview = v.summary[:40].replace("\n", " ")
|
|
132
160
|
if len(v.summary) > 40:
|
|
133
161
|
summary_preview += "..."
|
|
134
|
-
lines.append(f" -
|
|
162
|
+
lines.append(f" - @V{{{prev_offset}}} {date_part} {summary_preview}")
|
|
135
163
|
if version_nav.get("next"):
|
|
136
164
|
lines.append("next:")
|
|
137
165
|
for i, v in enumerate(version_nav["next"]):
|
|
138
|
-
# Offset for this next item: current_offset - i - 1
|
|
139
166
|
next_offset = current_offset - i - 1
|
|
140
|
-
date_part = v.created_at[:10] if v.created_at else "
|
|
167
|
+
date_part = v.created_at[:10] if v.created_at else ""
|
|
141
168
|
summary_preview = v.summary[:40].replace("\n", " ")
|
|
142
169
|
if len(v.summary) > 40:
|
|
143
170
|
summary_preview += "..."
|
|
144
|
-
lines.append(f" -
|
|
171
|
+
lines.append(f" - @V{{{next_offset}}} {date_part} {summary_preview}")
|
|
145
172
|
elif viewing_offset is not None:
|
|
146
173
|
# Viewing old version and next is empty means current is next
|
|
147
174
|
lines.append("next:")
|
|
148
|
-
lines.append(" -
|
|
175
|
+
lines.append(f" - @V{{0}}")
|
|
149
176
|
|
|
150
177
|
lines.append("---")
|
|
151
178
|
lines.append(item.summary) # Summary IS the content
|
|
152
179
|
return "\n".join(lines)
|
|
153
180
|
|
|
154
181
|
|
|
182
|
+
def _format_summary_line(item: Item) -> str:
|
|
183
|
+
"""Format item as single summary line: id@version date summary"""
|
|
184
|
+
# Get version-scoped ID
|
|
185
|
+
base_id = item.tags.get("_base_id", item.id)
|
|
186
|
+
version = item.tags.get("_version", "0")
|
|
187
|
+
versioned_id = f"{base_id}@V{{{version}}}"
|
|
188
|
+
|
|
189
|
+
# Get date (from _updated_date or _updated or _created)
|
|
190
|
+
date = item.tags.get("_updated_date") or item.tags.get("_updated", "")[:10] or item.tags.get("_created", "")[:10] or ""
|
|
191
|
+
|
|
192
|
+
# Truncate summary to ~60 chars, collapse newlines
|
|
193
|
+
summary = item.summary.replace("\n", " ")
|
|
194
|
+
if len(summary) > 60:
|
|
195
|
+
summary = summary[:57].rsplit(" ", 1)[0] + "..."
|
|
196
|
+
|
|
197
|
+
return f"{versioned_id} {date} {summary}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _format_versioned_id(item: Item) -> str:
|
|
201
|
+
"""Format item ID with version suffix: id@V{N}"""
|
|
202
|
+
base_id = item.tags.get("_base_id", item.id)
|
|
203
|
+
version = item.tags.get("_version", "0")
|
|
204
|
+
return f"{base_id}@V{{{version}}}"
|
|
205
|
+
|
|
206
|
+
|
|
155
207
|
@app.callback(invoke_without_command=True)
|
|
156
208
|
def main_callback(
|
|
157
209
|
ctx: typer.Context,
|
|
@@ -188,11 +240,13 @@ def main_callback(
|
|
|
188
240
|
item = kp.get_now()
|
|
189
241
|
version_nav = kp.get_version_nav(NOWDOC_ID, None, collection="default")
|
|
190
242
|
similar_items = kp.get_similar_for_display(NOWDOC_ID, limit=3, collection="default")
|
|
243
|
+
similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
|
|
191
244
|
typer.echo(_format_item(
|
|
192
245
|
item,
|
|
193
246
|
as_json=_get_json_output(),
|
|
194
247
|
version_nav=version_nav,
|
|
195
248
|
similar_items=similar_items,
|
|
249
|
+
similar_offsets=similar_offsets,
|
|
196
250
|
))
|
|
197
251
|
|
|
198
252
|
|
|
@@ -235,38 +289,39 @@ SinceOption = Annotated[
|
|
|
235
289
|
]
|
|
236
290
|
|
|
237
291
|
|
|
238
|
-
# -----------------------------------------------------------------------------
|
|
239
|
-
# Output Helpers
|
|
240
|
-
# -----------------------------------------------------------------------------
|
|
241
|
-
|
|
242
292
|
def _format_item(
|
|
243
293
|
item: Item,
|
|
244
294
|
as_json: bool = False,
|
|
245
295
|
version_nav: Optional[dict[str, list[VersionInfo]]] = None,
|
|
246
296
|
viewing_offset: Optional[int] = None,
|
|
247
297
|
similar_items: Optional[list[Item]] = None,
|
|
298
|
+
similar_offsets: Optional[dict[str, int]] = None,
|
|
248
299
|
) -> str:
|
|
249
300
|
"""
|
|
250
|
-
Format
|
|
301
|
+
Format a single item for display.
|
|
251
302
|
|
|
252
|
-
|
|
253
|
-
|
|
303
|
+
Output selection:
|
|
304
|
+
--ids: versioned ID only
|
|
305
|
+
--full or version_nav/similar_items present: YAML frontmatter
|
|
306
|
+
default: summary line (id@V{N} date summary)
|
|
254
307
|
|
|
255
308
|
Args:
|
|
256
309
|
item: The item to format
|
|
257
310
|
as_json: Output as JSON
|
|
258
|
-
version_nav:
|
|
259
|
-
viewing_offset:
|
|
260
|
-
similar_items:
|
|
311
|
+
version_nav: Version navigation info (triggers full format)
|
|
312
|
+
viewing_offset: Version offset if viewing old version (triggers full format)
|
|
313
|
+
similar_items: Similar items to display (triggers full format)
|
|
314
|
+
similar_offsets: Version offsets for similar items
|
|
261
315
|
"""
|
|
262
316
|
if _get_ids_output():
|
|
263
|
-
|
|
317
|
+
versioned_id = _format_versioned_id(item)
|
|
318
|
+
return json.dumps(versioned_id) if as_json else versioned_id
|
|
264
319
|
|
|
265
320
|
if as_json:
|
|
266
321
|
result = {
|
|
267
322
|
"id": item.id,
|
|
268
323
|
"summary": item.summary,
|
|
269
|
-
"tags": item.tags,
|
|
324
|
+
"tags": _filter_display_tags(item.tags),
|
|
270
325
|
"score": item.score,
|
|
271
326
|
}
|
|
272
327
|
if viewing_offset is not None:
|
|
@@ -274,7 +329,12 @@ def _format_item(
|
|
|
274
329
|
result["vid"] = f"{item.id}@V{{{viewing_offset}}}"
|
|
275
330
|
if similar_items:
|
|
276
331
|
result["similar"] = [
|
|
277
|
-
{
|
|
332
|
+
{
|
|
333
|
+
"id": f"{s.tags.get('_base_id', s.id)}@V{{{(similar_offsets or {}).get(s.id, 0)}}}",
|
|
334
|
+
"score": s.score,
|
|
335
|
+
"date": s.tags.get("_updated", s.tags.get("_created", ""))[:10],
|
|
336
|
+
"summary": s.summary[:60],
|
|
337
|
+
}
|
|
278
338
|
for s in similar_items
|
|
279
339
|
]
|
|
280
340
|
if version_nav:
|
|
@@ -304,13 +364,18 @@ def _format_item(
|
|
|
304
364
|
result["version_nav"]["next"] = [{"offset": 0, "vid": f"{item.id}@V{{0}}", "label": "current"}]
|
|
305
365
|
return json.dumps(result)
|
|
306
366
|
|
|
307
|
-
|
|
367
|
+
# Full format when:
|
|
368
|
+
# - --full flag is set
|
|
369
|
+
# - version navigation or similar items are provided (can't display in summary)
|
|
370
|
+
if _get_full_output() or version_nav or similar_items or viewing_offset is not None:
|
|
371
|
+
return _format_yaml_frontmatter(item, version_nav, viewing_offset, similar_items, similar_offsets)
|
|
372
|
+
return _format_summary_line(item)
|
|
308
373
|
|
|
309
374
|
|
|
310
375
|
def _format_items(items: list[Item], as_json: bool = False) -> str:
|
|
311
376
|
"""Format multiple items for display."""
|
|
312
377
|
if _get_ids_output():
|
|
313
|
-
ids = [item
|
|
378
|
+
ids = [_format_versioned_id(item) for item in items]
|
|
314
379
|
return json.dumps(ids) if as_json else "\n".join(ids)
|
|
315
380
|
|
|
316
381
|
if as_json:
|
|
@@ -318,15 +383,20 @@ def _format_items(items: list[Item], as_json: bool = False) -> str:
|
|
|
318
383
|
{
|
|
319
384
|
"id": item.id,
|
|
320
385
|
"summary": item.summary,
|
|
321
|
-
"tags": item.tags,
|
|
386
|
+
"tags": _filter_display_tags(item.tags),
|
|
322
387
|
"score": item.score,
|
|
323
388
|
}
|
|
324
389
|
for item in items
|
|
325
390
|
], indent=2)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
391
|
+
|
|
392
|
+
if not items:
|
|
393
|
+
return "No results."
|
|
394
|
+
|
|
395
|
+
# Full format: YAML frontmatter with double-newline separator
|
|
396
|
+
# Default: summary lines with single-newline separator
|
|
397
|
+
if _get_full_output():
|
|
398
|
+
return "\n\n".join(_format_yaml_frontmatter(item) for item in items)
|
|
399
|
+
return "\n".join(_format_summary_line(item) for item in items)
|
|
330
400
|
|
|
331
401
|
|
|
332
402
|
def _get_keeper(store: Optional[Path], collection: str) -> Keeper:
|
|
@@ -417,7 +487,7 @@ def find(
|
|
|
417
487
|
|
|
418
488
|
@app.command()
|
|
419
489
|
def search(
|
|
420
|
-
query: Annotated[str, typer.Argument(help="Full-text search query")],
|
|
490
|
+
query: Annotated[str, typer.Argument(default=..., help="Full-text search query")],
|
|
421
491
|
store: StoreOption = None,
|
|
422
492
|
collection: CollectionOption = "default",
|
|
423
493
|
limit: LimitOption = 10,
|
|
@@ -443,22 +513,11 @@ def list_recent(
|
|
|
443
513
|
"""
|
|
444
514
|
List recent items by update time.
|
|
445
515
|
|
|
446
|
-
|
|
516
|
+
Default: summary lines. Use --ids for IDs only, --full for YAML.
|
|
447
517
|
"""
|
|
448
518
|
kp = _get_keeper(store, collection)
|
|
449
519
|
results = kp.list_recent(limit=limit)
|
|
450
|
-
|
|
451
|
-
# Determine output mode: --full > --ids > command default (IDs for list)
|
|
452
|
-
if _get_json_output():
|
|
453
|
-
# JSON always outputs full items
|
|
454
|
-
typer.echo(_format_items(results, as_json=True))
|
|
455
|
-
elif _get_full_output():
|
|
456
|
-
# --full flag: full YAML output
|
|
457
|
-
typer.echo(_format_items(results, as_json=False))
|
|
458
|
-
else:
|
|
459
|
-
# Default for list: IDs only (composable)
|
|
460
|
-
for item in results:
|
|
461
|
-
typer.echo(item.id)
|
|
520
|
+
typer.echo(_format_items(results, as_json=_get_json_output()))
|
|
462
521
|
|
|
463
522
|
|
|
464
523
|
@app.command()
|
|
@@ -519,7 +578,7 @@ def tag(
|
|
|
519
578
|
|
|
520
579
|
@app.command("tag-update")
|
|
521
580
|
def tag_update(
|
|
522
|
-
ids: Annotated[list[str], typer.Argument(help="Document IDs to tag")],
|
|
581
|
+
ids: Annotated[list[str], typer.Argument(default=..., help="Document IDs to tag")],
|
|
523
582
|
tags: Annotated[Optional[list[str]], typer.Option(
|
|
524
583
|
"--tag", "-t",
|
|
525
584
|
help="Tag as key=value (empty value removes: key=)"
|
|
@@ -629,7 +688,7 @@ def update(
|
|
|
629
688
|
# Use content-addressed ID for stdin text (enables versioning)
|
|
630
689
|
doc_id = id or _text_content_id(content)
|
|
631
690
|
item = kp.remember(content, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
|
|
632
|
-
elif source and
|
|
691
|
+
elif source and _URI_SCHEME_PATTERN.match(source):
|
|
633
692
|
# URI mode: fetch from URI (ID is the URI itself)
|
|
634
693
|
item = kp.update(source, tags=parsed_tags or None, summary=summary, lazy=lazy)
|
|
635
694
|
elif source:
|
|
@@ -807,17 +866,19 @@ def now(
|
|
|
807
866
|
item = kp.get_now()
|
|
808
867
|
version_nav = kp.get_version_nav(NOWDOC_ID, None, collection=collection)
|
|
809
868
|
similar_items = kp.get_similar_for_display(NOWDOC_ID, limit=3, collection=collection)
|
|
869
|
+
similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
|
|
810
870
|
typer.echo(_format_item(
|
|
811
871
|
item,
|
|
812
872
|
as_json=_get_json_output(),
|
|
813
873
|
version_nav=version_nav,
|
|
814
874
|
similar_items=similar_items,
|
|
875
|
+
similar_offsets=similar_offsets,
|
|
815
876
|
))
|
|
816
877
|
|
|
817
878
|
|
|
818
879
|
@app.command()
|
|
819
880
|
def get(
|
|
820
|
-
id: Annotated[str, typer.Argument(help="URI of item (append @V{N} for version)")],
|
|
881
|
+
id: Annotated[str, typer.Argument(default=..., help="URI of item (append @V{N} for version)")],
|
|
821
882
|
version: Annotated[Optional[int], typer.Option(
|
|
822
883
|
"--version", "-V",
|
|
823
884
|
help="Get specific version (0=current, 1=previous, etc.)"
|
|
@@ -927,18 +988,22 @@ def get(
|
|
|
927
988
|
if similar:
|
|
928
989
|
# List similar items
|
|
929
990
|
similar_items = kp.get_similar_for_display(actual_id, limit=limit, collection=collection)
|
|
991
|
+
similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
|
|
930
992
|
|
|
931
993
|
if _get_ids_output():
|
|
932
|
-
# Output IDs one per line
|
|
994
|
+
# Output version-scoped IDs one per line
|
|
933
995
|
for item in similar_items:
|
|
934
|
-
|
|
996
|
+
base_id = item.tags.get("_base_id", item.id)
|
|
997
|
+
offset = similar_offsets.get(item.id, 0)
|
|
998
|
+
typer.echo(f"{base_id}@V{{{offset}}}")
|
|
935
999
|
elif _get_json_output():
|
|
936
1000
|
result = {
|
|
937
1001
|
"id": actual_id,
|
|
938
1002
|
"similar": [
|
|
939
1003
|
{
|
|
940
|
-
"id": item.id,
|
|
1004
|
+
"id": f"{item.tags.get('_base_id', item.id)}@V{{{similar_offsets.get(item.id, 0)}}}",
|
|
941
1005
|
"score": item.score,
|
|
1006
|
+
"date": item.tags.get("_updated", item.tags.get("_created", ""))[:10],
|
|
942
1007
|
"summary": item.summary[:60],
|
|
943
1008
|
}
|
|
944
1009
|
for item in similar_items
|
|
@@ -949,11 +1014,14 @@ def get(
|
|
|
949
1014
|
typer.echo(f"Similar to {actual_id}:")
|
|
950
1015
|
if similar_items:
|
|
951
1016
|
for item in similar_items:
|
|
1017
|
+
base_id = item.tags.get("_base_id", item.id)
|
|
1018
|
+
offset = similar_offsets.get(item.id, 0)
|
|
952
1019
|
score_str = f"({item.score:.2f})" if item.score else ""
|
|
1020
|
+
date_part = item.tags.get("_updated", item.tags.get("_created", ""))[:10]
|
|
953
1021
|
summary_preview = item.summary[:50].replace("\n", " ")
|
|
954
1022
|
if len(item.summary) > 50:
|
|
955
1023
|
summary_preview += "..."
|
|
956
|
-
typer.echo(f" {
|
|
1024
|
+
typer.echo(f" {base_id}@V{{{offset}}} {score_str} {date_part} {summary_preview}")
|
|
957
1025
|
else:
|
|
958
1026
|
typer.echo(" No similar items found.")
|
|
959
1027
|
return
|
|
@@ -985,8 +1053,10 @@ def get(
|
|
|
985
1053
|
|
|
986
1054
|
# Get similar items (unless suppressed or viewing old version)
|
|
987
1055
|
similar_items = None
|
|
1056
|
+
similar_offsets = None
|
|
988
1057
|
if not no_similar and offset == 0:
|
|
989
1058
|
similar_items = kp.get_similar_for_display(actual_id, limit=3, collection=collection)
|
|
1059
|
+
similar_offsets = {s.id: kp.get_version_offset(s) for s in similar_items}
|
|
990
1060
|
|
|
991
1061
|
typer.echo(_format_item(
|
|
992
1062
|
item,
|
|
@@ -994,6 +1064,7 @@ def get(
|
|
|
994
1064
|
version_nav=version_nav,
|
|
995
1065
|
viewing_offset=offset if offset > 0 else None,
|
|
996
1066
|
similar_items=similar_items,
|
|
1067
|
+
similar_offsets=similar_offsets,
|
|
997
1068
|
))
|
|
998
1069
|
|
|
999
1070
|
|
|
@@ -1019,6 +1090,10 @@ def list_collections(
|
|
|
1019
1090
|
|
|
1020
1091
|
@app.command()
|
|
1021
1092
|
def init(
|
|
1093
|
+
reset_system_docs: Annotated[bool, typer.Option(
|
|
1094
|
+
"--reset-system-docs",
|
|
1095
|
+
help="Force reload system documents from bundled content (overwrites modifications)"
|
|
1096
|
+
)] = False,
|
|
1022
1097
|
store: StoreOption = None,
|
|
1023
1098
|
collection: CollectionOption = "default",
|
|
1024
1099
|
):
|
|
@@ -1027,6 +1102,11 @@ def init(
|
|
|
1027
1102
|
"""
|
|
1028
1103
|
kp = _get_keeper(store, collection)
|
|
1029
1104
|
|
|
1105
|
+
# Handle reset if requested
|
|
1106
|
+
if reset_system_docs:
|
|
1107
|
+
stats = kp.reset_system_documents()
|
|
1108
|
+
typer.echo(f"Reset {stats['reset']} system documents")
|
|
1109
|
+
|
|
1030
1110
|
# Show config and store paths
|
|
1031
1111
|
config = kp._config
|
|
1032
1112
|
config_path = config.config_path if config else None
|
|
@@ -14,14 +14,12 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Any, Optional
|
|
15
15
|
|
|
16
16
|
# tomli_w for writing TOML (tomllib is read-only)
|
|
17
|
-
|
|
18
|
-
import tomli_w
|
|
19
|
-
except ImportError:
|
|
20
|
-
tomli_w = None # type: ignore
|
|
17
|
+
import tomli_w
|
|
21
18
|
|
|
22
19
|
|
|
23
20
|
CONFIG_FILENAME = "keep.toml"
|
|
24
21
|
CONFIG_VERSION = 3 # Bumped for document versioning support
|
|
22
|
+
SYSTEM_DOCS_VERSION = 1 # Increment when bundled system docs content changes
|
|
25
23
|
|
|
26
24
|
|
|
27
25
|
@dataclass
|
|
@@ -91,6 +89,9 @@ class StoreConfig:
|
|
|
91
89
|
# Maximum length for summaries (used for smart remember and validation)
|
|
92
90
|
max_summary_length: int = 500
|
|
93
91
|
|
|
92
|
+
# System docs version (tracks which bundled docs have been applied to this store)
|
|
93
|
+
system_docs_version: int = 0
|
|
94
|
+
|
|
94
95
|
@property
|
|
95
96
|
def config_path(self) -> Path:
|
|
96
97
|
"""Path to the TOML config file."""
|
|
@@ -354,6 +355,9 @@ def load_config(config_dir: Path) -> StoreConfig:
|
|
|
354
355
|
# Parse max_summary_length (default 500)
|
|
355
356
|
max_summary_length = data.get("store", {}).get("max_summary_length", 500)
|
|
356
357
|
|
|
358
|
+
# Parse system_docs_version (default 0 for stores that predate this feature)
|
|
359
|
+
system_docs_version = data.get("store", {}).get("system_docs_version", 0)
|
|
360
|
+
|
|
357
361
|
return StoreConfig(
|
|
358
362
|
path=actual_store,
|
|
359
363
|
config_dir=config_dir,
|
|
@@ -366,6 +370,7 @@ def load_config(config_dir: Path) -> StoreConfig:
|
|
|
366
370
|
embedding_identity=parse_embedding_identity(data.get("embedding_identity")),
|
|
367
371
|
default_tags=default_tags,
|
|
368
372
|
max_summary_length=max_summary_length,
|
|
373
|
+
system_docs_version=system_docs_version,
|
|
369
374
|
)
|
|
370
375
|
|
|
371
376
|
|
|
@@ -387,9 +392,6 @@ def save_config(config: StoreConfig) -> None:
|
|
|
387
392
|
|
|
388
393
|
Creates the directory if it doesn't exist.
|
|
389
394
|
"""
|
|
390
|
-
if tomli_w is None:
|
|
391
|
-
raise RuntimeError("tomli_w is required to save config. Install with: pip install tomli-w")
|
|
392
|
-
|
|
393
395
|
# Ensure config directory exists
|
|
394
396
|
config_location = config.config_dir if config.config_dir else config.path
|
|
395
397
|
config_location.mkdir(parents=True, exist_ok=True)
|
|
@@ -410,6 +412,9 @@ def save_config(config: StoreConfig) -> None:
|
|
|
410
412
|
# Only write max_summary_length if not default
|
|
411
413
|
if config.max_summary_length != 500:
|
|
412
414
|
store_section["max_summary_length"] = config.max_summary_length
|
|
415
|
+
# Write system_docs_version if set (tracks migration state)
|
|
416
|
+
if config.system_docs_version > 0:
|
|
417
|
+
store_section["system_docs_version"] = config.system_docs_version
|
|
413
418
|
|
|
414
419
|
data = {
|
|
415
420
|
"store": store_section,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Package data for keep
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# System documents for keep
|
|
@@ -58,15 +58,9 @@ class ChromaStore:
|
|
|
58
58
|
embedding_dimension: Expected dimension of embeddings (for validation).
|
|
59
59
|
Can be None for read-only access; will be set on first write.
|
|
60
60
|
"""
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
except ImportError:
|
|
65
|
-
raise RuntimeError(
|
|
66
|
-
"ChromaStore requires 'chromadb' library. "
|
|
67
|
-
"Install with: pip install chromadb"
|
|
68
|
-
)
|
|
69
|
-
|
|
61
|
+
import chromadb
|
|
62
|
+
from chromadb.config import Settings
|
|
63
|
+
|
|
70
64
|
self._store_path = store_path
|
|
71
65
|
self._embedding_dimension = embedding_dimension
|
|
72
66
|
|
|
@@ -9,6 +9,10 @@ from typing import Optional
|
|
|
9
9
|
# System tag prefix - tags starting with this are managed by the system
|
|
10
10
|
SYSTEM_TAG_PREFIX = "_"
|
|
11
11
|
|
|
12
|
+
# Tags used internally but hidden from display output
|
|
13
|
+
# These exist for efficient queries/sorting but aren't user-facing
|
|
14
|
+
INTERNAL_TAGS = frozenset({"_updated_date"})
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
def filter_non_system_tags(tags: dict[str, str]) -> dict[str, str]:
|
|
14
18
|
"""
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "keep-skill"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Semantic memory - remember and search documents by meaning"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11,<3.14"
|
|
@@ -72,16 +72,11 @@ keep = "keep.cli:main"
|
|
|
72
72
|
|
|
73
73
|
[tool.hatch.build.targets.wheel]
|
|
74
74
|
packages = ["keep"]
|
|
75
|
-
artifacts = [
|
|
76
|
-
"SKILL.md",
|
|
77
|
-
"docs/system/**/*.md",
|
|
78
|
-
]
|
|
79
75
|
|
|
80
76
|
[tool.hatch.build.targets.sdist]
|
|
81
77
|
include = [
|
|
82
78
|
"/keep",
|
|
83
79
|
"/SKILL.md",
|
|
84
|
-
"/docs/system",
|
|
85
80
|
"/README.md",
|
|
86
81
|
"/LICENSE",
|
|
87
82
|
]
|
|
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
|