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.
- {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/workflows/ci.yml +8 -2
- {kore_memory-2.3.0 → kore_memory-2.5.0}/CHANGELOG.md +66 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/PKG-INFO +3 -1
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/config.py +1 -1
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/database.py +5 -3
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/filesystem_overlay.py +1 -4
- kore_memory-2.5.0/kore_memory/filesystem_watcher.py +343 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/main.py +66 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/mcp_server.py +59 -7
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/models.py +37 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/search.py +4 -1
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/templates/dashboard.html +6 -2
- {kore_memory-2.3.0 → kore_memory-2.5.0}/pyproject.toml +4 -1
- {kore_memory-2.3.0 → kore_memory-2.5.0}/scripts/assert_benchmarks.py +22 -5
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/package.json +1 -1
- kore_memory-2.5.0/tests/test_wave3_watcher.py +415 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/workflows/benchmark.yml +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/workflows/build-sdk.yml +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/.github/workflows/publish.yml +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/.gitignore +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/CONTRIBUTING.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/LICENSE +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/MIGRATION-v0.6.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/README.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/article-devto.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/assets/favicon.svg +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/assets/logo.svg +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/docs/competitive-analysis-2025.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/docs/quickstart-v2.1.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/docs/v1.0-roadmap.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/examples/async_usage.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/examples/basic_usage.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/examples/langchain_example.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/hatch_build.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore-daemon.sh +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/__init__.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/acl.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/analytics.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/audit.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/auth.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/auto_tuner.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/cli.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/client.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/compressor.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/conflict_detector.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/context_assembler.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/dashboard.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/decay.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/embedder.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/events.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/__init__.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/crewai.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/entities.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/langchain.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/openai_agents.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/integrations/pydantic_ai.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/plugins.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/py.typed +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/ranking.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/__init__.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/graph.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/lifecycle.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/memory.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/repository/sessions.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/scorer.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/summarizer.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/vector_index.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/kore_memory/welcome.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/presets/claude-code/README.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/presets/claude-code/mcp.json +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/presets/cursor/README.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/presets/cursor/mcp.json +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/requirements.txt +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/scripts/import_memory.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/.gitignore +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/README.md +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/package-lock.json +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/src/client.ts +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/src/errors.ts +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/src/index.ts +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/src/types.ts +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tests/client.test.ts +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tests/errors.test.ts +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tests/helpers.ts +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tsconfig.json +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/tsup.config.ts +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/sdk/js/vitest.config.ts +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/start.sh +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/test_pagination_fix.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/__init__.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/__init__.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/conftest_bench.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_a_temporal.json +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_b_conflicts.json +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_c_coding.json +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_d_graph.json +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/dataset_e_context.json +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/datasets/loaders.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/benchmarks/test_benchmarks.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/conftest.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_api.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_audit.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_auth_events.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_auto_tuner.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_cli.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_client.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_client_sync.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_conflict_detection.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_crewai.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_dashboard.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_entities.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_langchain.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_mcp.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_ranking_engine.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_sessions.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_v11_fixes.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_v12_features.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_v21_temporal.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_v2_features.py +0 -0
- {kore_memory-2.3.0 → kore_memory-2.5.0}/tests/test_wave3_graph.py +0 -0
- {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
|
-
#
|
|
154
|
-
|
|
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
|
+
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">
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|