keep-skill 0.4.1__py3-none-any.whl → 0.6.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 CHANGED
@@ -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.4.1"
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
  ]
keep/api.py CHANGED
@@ -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 = Path(__file__).parent.parent / "docs" / "system"
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
- # Preload system documents (only if not already present)
299
- self._ensure_system_documents()
353
+ # Migrate and ensure system documents (idempotent)
354
+ self._migrate_system_documents()
300
355
 
301
- def _ensure_system_documents(self) -> None:
356
+ def _migrate_system_documents(self) -> dict:
302
357
  """
303
- Ensure system documents are loaded into the store.
358
+ Migrate system documents to stable IDs and current version.
304
359
 
305
- Scans all .md files in docs/system/. Each file is indexed with its
306
- file:// URI as the ID and `_category: system` tag for identification.
307
- Content becomes the summary directly (no auto-summarization).
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 and no network access occurs
311
- if docs are already present.
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
- uri = f"file://{path.resolve()}"
316
- if not self.exists(uri):
317
- content, tags = _load_frontmatter(path)
318
- tags["category"] = "system"
319
- self.remember(content, id=uri, tags=tags)
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 Item(
585
- id=doc_record.id,
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 Item(
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 [Item(id=d.id, summary=d.summary, tags=d.tags) for d in docs]
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 Item(
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
- docs/system/now.md.
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: