kore-memory 2.3.0__tar.gz → 2.5.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.
Files changed (124) hide show
  1. {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/workflows/ci.yml +8 -2
  2. {kore_memory-2.3.0 → kore_memory-2.5.0}/CHANGELOG.md +66 -0
  3. {kore_memory-2.3.0 → kore_memory-2.5.0}/PKG-INFO +3 -1
  4. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/config.py +1 -1
  5. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/database.py +5 -3
  6. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/filesystem_overlay.py +1 -4
  7. kore_memory-2.5.0/kore_memory/filesystem_watcher.py +343 -0
  8. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/main.py +66 -0
  9. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/mcp_server.py +59 -7
  10. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/models.py +37 -0
  11. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/search.py +4 -1
  12. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/templates/dashboard.html +6 -2
  13. {kore_memory-2.3.0 → kore_memory-2.5.0}/pyproject.toml +4 -1
  14. {kore_memory-2.3.0 → kore_memory-2.5.0}/scripts/assert_benchmarks.py +22 -5
  15. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/package.json +1 -1
  16. kore_memory-2.5.0/tests/test_wave3_watcher.py +415 -0
  17. {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/workflows/benchmark.yml +0 -0
  21. {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/workflows/build-sdk.yml +0 -0
  22. {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/workflows/publish.yml +0 -0
  23. {kore_memory-2.3.0 → kore_memory-2.5.0}/.gitignore +0 -0
  24. {kore_memory-2.3.0 → kore_memory-2.5.0}/CONTRIBUTING.md +0 -0
  25. {kore_memory-2.3.0 → kore_memory-2.5.0}/LICENSE +0 -0
  26. {kore_memory-2.3.0 → kore_memory-2.5.0}/MIGRATION-v0.6.md +0 -0
  27. {kore_memory-2.3.0 → kore_memory-2.5.0}/README.md +0 -0
  28. {kore_memory-2.3.0 → kore_memory-2.5.0}/article-devto.md +0 -0
  29. {kore_memory-2.3.0 → kore_memory-2.5.0}/assets/favicon.svg +0 -0
  30. {kore_memory-2.3.0 → kore_memory-2.5.0}/assets/logo.svg +0 -0
  31. {kore_memory-2.3.0 → kore_memory-2.5.0}/docs/competitive-analysis-2025.md +0 -0
  32. {kore_memory-2.3.0 → kore_memory-2.5.0}/docs/quickstart-v2.1.md +0 -0
  33. {kore_memory-2.3.0 → kore_memory-2.5.0}/docs/v1.0-roadmap.md +0 -0
  34. {kore_memory-2.3.0 → kore_memory-2.5.0}/examples/async_usage.py +0 -0
  35. {kore_memory-2.3.0 → kore_memory-2.5.0}/examples/basic_usage.py +0 -0
  36. {kore_memory-2.3.0 → kore_memory-2.5.0}/examples/langchain_example.py +0 -0
  37. {kore_memory-2.3.0 → kore_memory-2.5.0}/hatch_build.py +0 -0
  38. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore-daemon.sh +0 -0
  39. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/__init__.py +0 -0
  40. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/acl.py +0 -0
  41. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/analytics.py +0 -0
  42. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/audit.py +0 -0
  43. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/auth.py +0 -0
  44. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/auto_tuner.py +0 -0
  45. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/cli.py +0 -0
  46. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/client.py +0 -0
  47. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/compressor.py +0 -0
  48. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/conflict_detector.py +0 -0
  49. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/context_assembler.py +0 -0
  50. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/dashboard.py +0 -0
  51. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/decay.py +0 -0
  52. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/embedder.py +0 -0
  53. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/events.py +0 -0
  54. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/__init__.py +0 -0
  55. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/crewai.py +0 -0
  56. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/entities.py +0 -0
  57. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/langchain.py +0 -0
  58. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/openai_agents.py +0 -0
  59. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/pydantic_ai.py +0 -0
  60. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/plugins.py +0 -0
  61. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/py.typed +0 -0
  62. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/ranking.py +0 -0
  63. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/__init__.py +0 -0
  64. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/graph.py +0 -0
  65. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/lifecycle.py +0 -0
  66. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/memory.py +0 -0
  67. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/sessions.py +0 -0
  68. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/scorer.py +0 -0
  69. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/summarizer.py +0 -0
  70. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/vector_index.py +0 -0
  71. {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/welcome.py +0 -0
  72. {kore_memory-2.3.0 → kore_memory-2.5.0}/presets/claude-code/README.md +0 -0
  73. {kore_memory-2.3.0 → kore_memory-2.5.0}/presets/claude-code/mcp.json +0 -0
  74. {kore_memory-2.3.0 → kore_memory-2.5.0}/presets/cursor/README.md +0 -0
  75. {kore_memory-2.3.0 → kore_memory-2.5.0}/presets/cursor/mcp.json +0 -0
  76. {kore_memory-2.3.0 → kore_memory-2.5.0}/requirements.txt +0 -0
  77. {kore_memory-2.3.0 → kore_memory-2.5.0}/scripts/import_memory.py +0 -0
  78. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/.gitignore +0 -0
  79. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/README.md +0 -0
  80. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/package-lock.json +0 -0
  81. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/src/client.ts +0 -0
  82. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/src/errors.ts +0 -0
  83. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/src/index.ts +0 -0
  84. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/src/types.ts +0 -0
  85. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tests/client.test.ts +0 -0
  86. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tests/errors.test.ts +0 -0
  87. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tests/helpers.ts +0 -0
  88. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tsconfig.json +0 -0
  89. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tsup.config.ts +0 -0
  90. {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/vitest.config.ts +0 -0
  91. {kore_memory-2.3.0 → kore_memory-2.5.0}/start.sh +0 -0
  92. {kore_memory-2.3.0 → kore_memory-2.5.0}/test_pagination_fix.py +0 -0
  93. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/__init__.py +0 -0
  94. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/__init__.py +0 -0
  95. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/conftest_bench.py +0 -0
  96. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_a_temporal.json +0 -0
  97. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_b_conflicts.json +0 -0
  98. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_c_coding.json +0 -0
  99. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_d_graph.json +0 -0
  100. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_e_context.json +0 -0
  101. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/loaders.py +0 -0
  102. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/test_benchmarks.py +0 -0
  103. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/conftest.py +0 -0
  104. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_api.py +0 -0
  105. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_audit.py +0 -0
  106. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_auth_events.py +0 -0
  107. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_auto_tuner.py +0 -0
  108. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_cli.py +0 -0
  109. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_client.py +0 -0
  110. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_client_sync.py +0 -0
  111. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_conflict_detection.py +0 -0
  112. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_crewai.py +0 -0
  113. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_dashboard.py +0 -0
  114. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_entities.py +0 -0
  115. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_langchain.py +0 -0
  116. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_mcp.py +0 -0
  117. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_ranking_engine.py +0 -0
  118. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_sessions.py +0 -0
  119. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_v11_fixes.py +0 -0
  120. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_v12_features.py +0 -0
  121. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_v21_temporal.py +0 -0
  122. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_v2_features.py +0 -0
  123. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_wave3_graph.py +0 -0
  124. {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_wave3_overlay.py +0 -0
@@ -150,8 +150,14 @@ jobs:
150
150
  run: bandit -r kore_memory/ -c pyproject.toml
151
151
 
152
152
  - name: Run pip-audit (CVE check)
153
- # --skip-editable: salta kore-memory stesso (non su PyPI, non ha CVE propri)
154
- run: pip-audit --strict --skip-editable
153
+ # Audita solo le dipendenze installate, non il package locale (non è su PyPI)
154
+ # Usiamo -r requirements-audit.txt generato al volo per evitare problemi con editable installs
155
+ run: |
156
+ pip list --format=freeze \
157
+ | grep -v "^kore-memory==" \
158
+ | grep -v "^kore_memory==" \
159
+ > /tmp/audit_reqs.txt
160
+ pip-audit -r /tmp/audit_reqs.txt
155
161
 
156
162
  lint:
157
163
  runs-on: ubuntu-latest
@@ -11,6 +11,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  ---
13
13
 
14
+ ## [2.5.0] - 2026-04-14
15
+
16
+ ### Theme: "Filesystem Watcher — Live Overlay Sync"
17
+
18
+ ### Added
19
+
20
+ #### Filesystem Watcher (`kore_memory/filesystem_watcher.py`)
21
+ - **Nuovo modulo** che monitora in background le directory tramite `watchdog` e aggiorna automaticamente il filesystem overlay quando i file cambiano su disco
22
+ - `_WatcherRegistry`: thread-safe registry dei watcher attivi, keyed by `agent_id:base_path`
23
+ - `_KoreFileHandler`: gestisce CREATE/MODIFY con debounce 1s (evita burst da IDE auto-save), DELETE immediato, MOVE = remove vecchio + reindex nuovo
24
+ - Estensioni monitorate: `.md .rst .toml .txt .json .yaml .yml .py .cfg` + nomi file da `DEFAULT_PATTERNS`
25
+ - **Graceful degradation**: se `watchdog` non è installato, il modulo carica senza crash (`_HAS_WATCHDOG` flag), `start_watcher()` solleva `ImportError` chiaro
26
+ - API pubblica: `start_watcher`, `stop_watcher`, `stop_all_watchers`, `list_watchers`, `is_available`
27
+ - Graceful shutdown: `stop_all_watchers()` chiamato nel lifespan FastAPI prima del pool cleanup
28
+
29
+ #### Nuovi endpoint REST
30
+ - `POST /overlay/watch` — avvia watcher per `base_path` (503 se watchdog non installato, 400 se path non valido)
31
+ - `DELETE /overlay/watch?path=...` — ferma watcher per path/agent
32
+ - `GET /overlay/watchers` — lista watcher attivi con statistiche (`events_processed`, `active`, `started_at`)
33
+
34
+ #### Nuovi modelli Pydantic
35
+ - `OverlayWatchRequest`, `OverlayWatchResponse`, `OverlayWatcherRecord`, `OverlayWatchersResponse`
36
+
37
+ #### Nuova optional dependency
38
+ - `pip install 'kore-memory[watcher]'` installa `watchdog>=4.0.0`
39
+
40
+ ### Tests
41
+ - **35 test** nel nuovo `tests/test_wave3_watcher.py` (2 skip se watchdog non installato):
42
+ - `TestWatcherAvailability` (3 test): flag, is_available(), ImportError senza watchdog
43
+ - `TestWatcherRegistry` (8 test): key format, add/get/remove, stop_all
44
+ - `TestKoreFileHandler` (12 test): is_relevant, debounce, on_modified/created/deleted/moved, do_reindex/do_remove
45
+ - `TestStartStopWatcher` (7 test): start/stop lifecycle, already_running, list
46
+ - `TestWatcherEndpoints` (6 test): REST API GET/POST/DELETE + full lifecycle
47
+ - Suite totale: **629 test** (da 572 → +57 inclusi test v2.4.0 precedenti)
48
+
49
+ ---
50
+
51
+ ## [2.4.0] - 2026-04-14
52
+
53
+ ### Theme: "Dashboard Fixes + MCP Auto-Session"
54
+
55
+ ### Added
56
+
57
+ #### MCP Auto-Session
58
+ - **Auto-creazione sessione** all'avvio di `kore-mcp`: il primo `memory_save` per ogni `agent_id` crea automaticamente una sessione con ID `kore-mcp-{agent_id}-{YYYYMMDD-HHMMSS}`
59
+ - **Thread-safe**: creazione lazy con double-checked locking (`_get_or_create_session`)
60
+ - **atexit handler**: chiude tutte le sessioni aperte alla terminazione del processo MCP
61
+ - **Propagazione automatica** a `memory_save`, `memory_save_batch`, `memory_save_decision`, `memory_log_regression`
62
+ - La risposta di `memory_save` include ora `session_id` per tracciabilità
63
+ - La tab **Sessions** della dashboard si popola organicamente con una riga per ogni conversazione Claude/MCP
64
+
65
+ ### Fixed
66
+
67
+ #### Dashboard
68
+ - **Tab Memories**: `_count_active_memories` non gestiva il wildcard `q=*` come `_fts_search` — con query `*` costruiva `LIKE "%*%"` (cerca il carattere asterisco) invece di `LIKE "%%"` (match all). `total` tornava 2 invece di 40
69
+ - **Checkbox Semantic**: rimosso `checked` di default — gli utenti senza `sentence-transformers` ottenevano risultati imprevedibili; il default sicuro è `semantic=false`
70
+ - **Tab Timeline**: mancava `case 'timeline':` in `loadPageData()` — la tab si apriva con `#timeline-list` completamente vuoto, senza messaggi né dati. Fix: auto-carica le ultime memorie in ordine cronologico (`subject=*`) con header contestuale
71
+
72
+ #### Database
73
+ - **`idx_relations_strength`**: l'indice era nell'`executescript()` iniziale prima che la colonna `strength` venisse aggiunta via ALTER TABLE. Su DB pre-esistenti causava `no such column: strength` al startup. Spostato in `executescript()` separato dopo `_v23_relation_migrations`
74
+
75
+ ### Tests
76
+ - Suite invariata: **572 test**, coverage ≥ 88%
77
+
78
+ ---
79
+
14
80
  ## [2.3.0] - 2026-04-14
15
81
 
16
82
  ### Theme: "Wave 3 — Graph Engine + Filesystem Overlay"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kore-memory
3
- Version: 2.3.0
3
+ Version: 2.5.0
4
4
  Summary: The memory layer that thinks like a human: remembers what matters, forgets what doesn't, and never calls home.
5
5
  Project-URL: Homepage, https://github.com/auriti-labs/kore-memory
6
6
  Project-URL: Documentation, https://auritidesign.it/docs
@@ -43,6 +43,8 @@ Requires-Dist: pydantic-ai>=0.0.49; extra == 'pydantic-ai'
43
43
  Provides-Extra: semantic
44
44
  Requires-Dist: sentence-transformers>=3.0.0; extra == 'semantic'
45
45
  Requires-Dist: sqlite-vec>=0.1.1; extra == 'semantic'
46
+ Provides-Extra: watcher
47
+ Requires-Dist: watchdog>=4.0.0; extra == 'watcher'
46
48
  Description-Content-Type: text/markdown
47
49
 
48
50
  <div align="center">
@@ -85,4 +85,4 @@ CONFLICT_MAX_CANDIDATES = int(os.getenv("KORE_CONFLICT_MAX_CANDIDATES", "10"))
85
85
 
86
86
  # ── Version ───────────────────────────────────────────────────────────────────
87
87
 
88
- VERSION = "2.3.0"
88
+ VERSION = "2.5.0"
@@ -215,7 +215,7 @@ def init_db() -> None:
215
215
  );
216
216
  CREATE INDEX IF NOT EXISTS idx_relations_source ON memory_relations (source_id);
217
217
  CREATE INDEX IF NOT EXISTS idx_relations_target ON memory_relations (target_id);
218
- CREATE INDEX IF NOT EXISTS idx_relations_strength ON memory_relations (strength DESC);
218
+ -- idx_relations_strength creato dopo la migrazione v2.3 (colonna strength)
219
219
 
220
220
  -- Audit / event log
221
221
  CREATE TABLE IF NOT EXISTS event_logs (
@@ -308,9 +308,11 @@ def init_db() -> None:
308
308
  for col, sql in _v23_relation_migrations.items():
309
309
  if col not in relation_cols:
310
310
  conn.execute(sql)
311
- conn.execute("""
311
+ # Indice su strength — creato qui (dopo ALTER TABLE) per DB pre-esistenti
312
+ # e per DB nuovi (la CREATE TABLE usa IF NOT EXISTS, l'indice viene saltato sopra)
313
+ conn.executescript("""
312
314
  CREATE INDEX IF NOT EXISTS idx_relations_strength
313
- ON memory_relations (strength DESC)
315
+ ON memory_relations (strength DESC);
314
316
  """)
315
317
 
316
318
 
@@ -287,10 +287,7 @@ def index_files(
287
287
 
288
288
  for chunk_content_text, chunk_idx in chunks:
289
289
  chunk_label = f" [{chunk_idx + 1}/{len(chunks)}]" if len(chunks) > 1 else ""
290
- enriched_content = (
291
- f"[File: {Path(filepath).name}{chunk_label}]\n"
292
- f"{chunk_content_text.strip()}"
293
- )
290
+ enriched_content = f"[File: {Path(filepath).name}{chunk_label}]\n{chunk_content_text.strip()}"
294
291
  # Tronca a 4000 chars (limite modello)
295
292
  enriched_content = enriched_content[:4000]
296
293
 
@@ -0,0 +1,343 @@
1
+ """
2
+ Kore — Filesystem Watcher (Wave 3, issue #025)
3
+
4
+ Auto-aggiorna il filesystem overlay quando i file monitorati cambiano su disco.
5
+ Richiede: pip install kore-memory[watcher] (watchdog>=4.0.0)
6
+
7
+ Funzionalità:
8
+ - Monitora i file dell'overlay in background via watchdog Observer
9
+ - CREATE/MODIFY → re-index con debounce da 1s (evita burst da auto-save IDE)
10
+ - DELETE → remove_file_from_overlay()
11
+ - Watcher per (base_path, agent_id) — multipli watchers supportati
12
+ - Graceful degradation: se watchdog non disponibile, errori chiari senza crash
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import threading
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .filesystem_overlay import DEFAULT_PATTERNS, index_files, remove_file_from_overlay
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # ── Disponibilità watchdog ────────────────────────────────────────────────────
28
+
29
+ try:
30
+ from watchdog.events import FileSystemEventHandler
31
+ from watchdog.observers import Observer as _Observer
32
+
33
+ _HAS_WATCHDOG = True
34
+ except ImportError:
35
+ _HAS_WATCHDOG = False
36
+ # Fallback per type annotations senza watchdog installato
37
+ FileSystemEventHandler = object # type: ignore[assignment,misc]
38
+
39
+ _DEBOUNCE_SECONDS = 1.0 # attesa dopo l'ultimo evento prima di re-indexare
40
+
41
+ # Estensioni di file monitorate dal watcher
42
+ _WATCHED_EXTENSIONS = frozenset({".md", ".rst", ".toml", ".txt", ".json", ".yaml", ".yml", ".py", ".cfg"})
43
+
44
+
45
+ # ── Struttura dati watcher ────────────────────────────────────────────────────
46
+
47
+
48
+ class _WatcherEntry:
49
+ """Rappresenta un watcher attivo per una (base_path, agent_id)."""
50
+
51
+ def __init__(
52
+ self,
53
+ base_path: str,
54
+ agent_id: str,
55
+ patterns: list[str] | None,
56
+ include_extra_md: bool,
57
+ max_depth: int,
58
+ ) -> None:
59
+ self.base_path = base_path
60
+ self.agent_id = agent_id
61
+ self.patterns = patterns
62
+ self.include_extra_md = include_extra_md
63
+ self.max_depth = max_depth
64
+ self.observer: Any = None
65
+ self.started_at: str = datetime.now(UTC).isoformat()
66
+ self.events_processed: int = 0
67
+
68
+
69
+ class _WatcherRegistry:
70
+ """Registry thread-safe dei watcher attivi."""
71
+
72
+ def __init__(self) -> None:
73
+ self._watchers: dict[str, _WatcherEntry] = {}
74
+ self._lock = threading.Lock()
75
+
76
+ @staticmethod
77
+ def _key(base_path: str, agent_id: str) -> str:
78
+ return f"{agent_id}:{base_path}"
79
+
80
+ def add(self, entry: _WatcherEntry) -> None:
81
+ with self._lock:
82
+ self._watchers[self._key(entry.base_path, entry.agent_id)] = entry
83
+
84
+ def remove(self, base_path: str, agent_id: str) -> _WatcherEntry | None:
85
+ with self._lock:
86
+ return self._watchers.pop(self._key(base_path, agent_id), None)
87
+
88
+ def get(self, base_path: str, agent_id: str) -> _WatcherEntry | None:
89
+ with self._lock:
90
+ return self._watchers.get(self._key(base_path, agent_id))
91
+
92
+ def list_all(self) -> list[dict]:
93
+ with self._lock:
94
+ return [
95
+ {
96
+ "base_path": e.base_path,
97
+ "agent_id": e.agent_id,
98
+ "started_at": e.started_at,
99
+ "events_processed": e.events_processed,
100
+ "active": e.observer is not None and e.observer.is_alive(),
101
+ }
102
+ for e in self._watchers.values()
103
+ ]
104
+
105
+ def stop_all(self) -> int:
106
+ """Ferma tutti i watcher — chiamato al shutdown del server."""
107
+ with self._lock:
108
+ entries = list(self._watchers.values())
109
+ self._watchers.clear()
110
+ count = 0
111
+ for entry in entries:
112
+ if entry.observer:
113
+ try:
114
+ entry.observer.stop()
115
+ entry.observer.join(timeout=2)
116
+ count += 1
117
+ except Exception:
118
+ pass
119
+ return count
120
+
121
+
122
+ _registry = _WatcherRegistry()
123
+
124
+
125
+ # ── Event handler ─────────────────────────────────────────────────────────────
126
+
127
+
128
+ class _KoreFileHandler(FileSystemEventHandler): # type: ignore[misc]
129
+ """Gestisce gli eventi del filesystem e aggiorna l'overlay con debounce."""
130
+
131
+ def __init__(self, entry: _WatcherEntry) -> None:
132
+ if _HAS_WATCHDOG:
133
+ super().__init__()
134
+ self._entry = entry
135
+ self._timers: dict[str, threading.Timer] = {}
136
+ self._timers_lock = threading.Lock()
137
+
138
+ # ── Debounce ──────────────────────────────────────────────────────────────
139
+
140
+ def _schedule_reindex(self, filepath: str) -> None:
141
+ """Annulla il timer pendente e ne avvia uno nuovo (debounce 1s)."""
142
+ with self._timers_lock:
143
+ existing = self._timers.get(filepath)
144
+ if existing:
145
+ existing.cancel()
146
+ timer = threading.Timer(_DEBOUNCE_SECONDS, self._do_reindex, args=[filepath])
147
+ self._timers[filepath] = timer
148
+ timer.start()
149
+
150
+ def _do_reindex(self, filepath: str) -> None:
151
+ """Re-indicizza il file nel filesystem overlay."""
152
+ with self._timers_lock:
153
+ self._timers.pop(filepath, None)
154
+
155
+ try:
156
+ path = Path(filepath)
157
+ if not path.exists() or not path.is_file():
158
+ return
159
+ stats = index_files(
160
+ filepaths=[path],
161
+ agent_id=self._entry.agent_id,
162
+ replace_existing=True,
163
+ )
164
+ self._entry.events_processed += 1
165
+ logger.info(
166
+ "Watcher re-indexed %s (agent=%s, updated=%d, indexed=%d)",
167
+ filepath,
168
+ self._entry.agent_id,
169
+ stats.get("updated", 0),
170
+ stats.get("indexed", 0),
171
+ )
172
+ except Exception as exc:
173
+ logger.error("Watcher: errore re-index %s: %s", filepath, exc)
174
+
175
+ def _do_remove(self, filepath: str) -> None:
176
+ """Rimuove le memories del file eliminato dall'overlay."""
177
+ try:
178
+ removed = remove_file_from_overlay(filepath, agent_id=self._entry.agent_id)
179
+ if removed:
180
+ self._entry.events_processed += 1
181
+ logger.info(
182
+ "Watcher: rimosso overlay per file eliminato %s (agent=%s, memories=%d)",
183
+ filepath,
184
+ self._entry.agent_id,
185
+ removed,
186
+ )
187
+ except Exception as exc:
188
+ logger.error("Watcher: errore rimozione overlay %s: %s", filepath, exc)
189
+
190
+ # ── Filtro file ───────────────────────────────────────────────────────────
191
+
192
+ def _is_relevant(self, filepath: str) -> bool:
193
+ """
194
+ True se il file è dentro base_path e ha un'estensione o nome rilevante.
195
+ """
196
+ path = Path(filepath)
197
+ try:
198
+ path.relative_to(self._entry.base_path)
199
+ except ValueError:
200
+ return False
201
+
202
+ if path.suffix.lower() in _WATCHED_EXTENSIONS:
203
+ return True
204
+
205
+ watched_names = set(self._entry.patterns or DEFAULT_PATTERNS)
206
+ return path.name in watched_names
207
+
208
+ # ── Handlers watchdog ─────────────────────────────────────────────────────
209
+
210
+ def on_modified(self, event: Any) -> None:
211
+ if not event.is_directory and self._is_relevant(event.src_path):
212
+ self._schedule_reindex(event.src_path)
213
+
214
+ def on_created(self, event: Any) -> None:
215
+ if not event.is_directory and self._is_relevant(event.src_path):
216
+ self._schedule_reindex(event.src_path)
217
+
218
+ def on_deleted(self, event: Any) -> None:
219
+ if not event.is_directory:
220
+ self._do_remove(event.src_path)
221
+
222
+ def on_moved(self, event: Any) -> None:
223
+ """Rinomina = elimina vecchio + indicizza nuovo."""
224
+ if not event.is_directory:
225
+ self._do_remove(event.src_path)
226
+ if self._is_relevant(event.dest_path):
227
+ self._schedule_reindex(event.dest_path)
228
+
229
+
230
+ # ── API pubblica ──────────────────────────────────────────────────────────────
231
+
232
+
233
+ def start_watcher(
234
+ base_path: str,
235
+ agent_id: str = "default",
236
+ patterns: list[str] | None = None,
237
+ include_extra_md: bool = True,
238
+ max_depth: int = 2,
239
+ ) -> dict:
240
+ """
241
+ Avvia un watcher per base_path. Richiede watchdog installato.
242
+
243
+ Raises:
244
+ ImportError: se watchdog non è installato
245
+ ValueError: se base_path non è una directory esistente
246
+ """
247
+ if not _HAS_WATCHDOG:
248
+ raise ImportError("watchdog non installato. Eseguire: pip install 'kore-memory[watcher]'")
249
+
250
+ resolved = str(Path(base_path).resolve())
251
+ if not Path(resolved).is_dir():
252
+ raise ValueError(f"base_path deve essere una directory esistente: {resolved!r}")
253
+
254
+ # Watcher già attivo per questa coppia (path, agent)?
255
+ existing = _registry.get(resolved, agent_id)
256
+ if existing and existing.observer and existing.observer.is_alive():
257
+ return {
258
+ "watching": True,
259
+ "already_running": True,
260
+ "base_path": resolved,
261
+ "agent_id": agent_id,
262
+ "started_at": existing.started_at,
263
+ "message": "Watcher già attivo",
264
+ }
265
+
266
+ entry = _WatcherEntry(
267
+ base_path=resolved,
268
+ agent_id=agent_id,
269
+ patterns=patterns,
270
+ include_extra_md=include_extra_md,
271
+ max_depth=max_depth,
272
+ )
273
+
274
+ handler = _KoreFileHandler(entry)
275
+ observer = _Observer()
276
+ observer.schedule(handler, resolved, recursive=True)
277
+ observer.start()
278
+
279
+ entry.observer = observer
280
+ _registry.add(entry)
281
+
282
+ logger.info("Watcher avviato: base_path=%s agent=%s", resolved, agent_id)
283
+ return {
284
+ "watching": True,
285
+ "already_running": False,
286
+ "base_path": resolved,
287
+ "agent_id": agent_id,
288
+ "started_at": entry.started_at,
289
+ "message": "Watcher avviato",
290
+ }
291
+
292
+
293
+ def stop_watcher(base_path: str, agent_id: str = "default") -> dict:
294
+ """Ferma il watcher per (base_path, agent_id)."""
295
+ resolved = str(Path(base_path).resolve())
296
+ entry = _registry.remove(resolved, agent_id)
297
+
298
+ if not entry:
299
+ return {
300
+ "stopped": False,
301
+ "base_path": resolved,
302
+ "agent_id": agent_id,
303
+ "message": "Nessun watcher attivo trovato",
304
+ }
305
+
306
+ if entry.observer:
307
+ try:
308
+ entry.observer.stop()
309
+ entry.observer.join(timeout=3)
310
+ except Exception as exc:
311
+ logger.warning("Errore stop observer %s: %s", resolved, exc)
312
+
313
+ logger.info(
314
+ "Watcher fermato: base_path=%s agent=%s events=%d",
315
+ resolved,
316
+ agent_id,
317
+ entry.events_processed,
318
+ )
319
+ return {
320
+ "stopped": True,
321
+ "base_path": resolved,
322
+ "agent_id": agent_id,
323
+ "events_processed": entry.events_processed,
324
+ "message": "Watcher fermato",
325
+ }
326
+
327
+
328
+ def stop_all_watchers() -> int:
329
+ """Ferma tutti i watcher attivi. Chiamato al shutdown del server."""
330
+ count = _registry.stop_all()
331
+ if count:
332
+ logger.info("Watcher: fermati %d observer al shutdown", count)
333
+ return count
334
+
335
+
336
+ def list_watchers() -> list[dict]:
337
+ """Restituisce la lista dei watcher attivi con statistiche."""
338
+ return _registry.list_all()
339
+
340
+
341
+ def is_available() -> bool:
342
+ """True se watchdog è installato e il watcher è disponibile."""
343
+ return _HAS_WATCHDOG
@@ -57,6 +57,10 @@ from .models import (
57
57
  OverlayFilesResponse,
58
58
  OverlayIndexRequest,
59
59
  OverlayIndexResponse,
60
+ OverlayWatcherRecord,
61
+ OverlayWatchersResponse,
62
+ OverlayWatchRequest,
63
+ OverlayWatchResponse,
60
64
  PluginListResponse,
61
65
  RelationRecord,
62
66
  RelationRequest,
@@ -222,6 +226,10 @@ async def lifespan(app: FastAPI):
222
226
 
223
227
  register_audit_handler()
224
228
  yield
229
+ # Graceful shutdown: ferma i watcher attivi
230
+ from .filesystem_watcher import stop_all_watchers
231
+
232
+ stop_all_watchers()
225
233
  # Graceful shutdown: close the SQLite connection pool
226
234
  from .database import _pool
227
235
 
@@ -1283,6 +1291,64 @@ def overlay_remove_file(
1283
1291
  return {"removed": removed, "path": path}
1284
1292
 
1285
1293
 
1294
+ # ── Filesystem Watcher (issue #025) ──────────────────────────────────────────
1295
+
1296
+
1297
+ @app.post("/overlay/watch", response_model=OverlayWatchResponse)
1298
+ def overlay_watch_start(
1299
+ body: OverlayWatchRequest,
1300
+ _: str = _Auth,
1301
+ agent_id: str = _Agent,
1302
+ ) -> OverlayWatchResponse:
1303
+ """
1304
+ Avvia un watcher su base_path che auto-aggiorna l'overlay al cambio dei file.
1305
+ Richiede: pip install kore-memory[watcher]
1306
+ """
1307
+ from .filesystem_watcher import start_watcher
1308
+
1309
+ try:
1310
+ patterns = body.patterns if body.patterns else None
1311
+ result = start_watcher(
1312
+ base_path=body.base_path,
1313
+ agent_id=agent_id,
1314
+ patterns=patterns,
1315
+ include_extra_md=body.include_extra_md,
1316
+ max_depth=body.max_depth,
1317
+ )
1318
+ return OverlayWatchResponse(**result)
1319
+ except ImportError as exc:
1320
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
1321
+ except ValueError as exc:
1322
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1323
+
1324
+
1325
+ @app.delete("/overlay/watch", response_model=dict)
1326
+ def overlay_watch_stop(
1327
+ path: str = Query(..., description="base_path del watcher da fermare"),
1328
+ _: str = _Auth,
1329
+ agent_id: str = _Agent,
1330
+ ) -> dict:
1331
+ """Ferma il watcher attivo per base_path."""
1332
+ from .filesystem_watcher import stop_watcher
1333
+
1334
+ return stop_watcher(base_path=path, agent_id=agent_id)
1335
+
1336
+
1337
+ @app.get("/overlay/watchers", response_model=OverlayWatchersResponse)
1338
+ def overlay_watchers(
1339
+ _: str = _Auth,
1340
+ ) -> OverlayWatchersResponse:
1341
+ """Lista tutti i watcher attivi con statistiche."""
1342
+ from .filesystem_watcher import is_available, list_watchers
1343
+
1344
+ watchers = list_watchers()
1345
+ return OverlayWatchersResponse(
1346
+ watchers=[OverlayWatcherRecord(**w) for w in watchers],
1347
+ total=len(watchers),
1348
+ watcher_available=is_available(),
1349
+ )
1350
+
1351
+
1286
1352
  # ── Audit log ────────────────────────────────────────────────────────────────
1287
1353
 
1288
1354