ltcai 9.9.2 → 9.9.4
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.
- package/README.md +44 -39
- package/docs/CHANGELOG.md +109 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/SURFACE_PARITY.md +47 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common.py +335 -0
- package/lattice_brain/graph/curator.py +141 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +382 -37
- package/lattice_brain/graph/retrieval.py +71 -4
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/ingestion.py +66 -0
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +33 -2
- package/latticeai/api/chat_agent_http.py +363 -6
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +217 -10
- package/latticeai/api/chat_stream.py +93 -3
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +65 -1
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +14 -0
- package/latticeai/core/agent.py +355 -18
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +404 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +25 -12
- package/latticeai/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +110 -2
- package/latticeai/services/folder_watch.py +396 -0
- package/latticeai/services/funnel_metrics.py +210 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +51 -5
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/bench_models.py +234 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +29 -27
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
- package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
- package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
- package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
- package/static/app/assets/Library-pKCK0_tk.js +1 -0
- package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
- package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
- package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/index-7FAfYm4v.css +2 -0
- package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/shield-alert-BftATuAA.js +1 -0
- package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
- package/static/app/assets/utils-SJUNVOj5.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-Cx_gZNbE.js +0 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
- package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
- package/static/app/assets/Library-PcfVUco6.js +0 -1
- package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
- package/static/app/assets/download-C7yxYHvz.js +0 -1
- package/static/app/assets/index-DU78Kogz.css +0 -2
- package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
- package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
|
@@ -560,6 +560,8 @@ class AutomationIntelligenceService:
|
|
|
560
560
|
except Exception:
|
|
561
561
|
LOGGER.exception("automation intelligence workflow read failed")
|
|
562
562
|
workflows = []
|
|
563
|
+
from latticeai.services.automation_execution import last_execution_view
|
|
564
|
+
|
|
563
565
|
for workflow in workflows:
|
|
564
566
|
metadata = (workflow or {}).get("metadata") or {}
|
|
565
567
|
if metadata.get("created_from") not in {"automation_suggestion", "brain_automation_recipe"}:
|
|
@@ -573,6 +575,11 @@ class AutomationIntelligenceService:
|
|
|
573
575
|
"enabled": metadata.get("automation_state") == "enabled",
|
|
574
576
|
"requires_user_enable": bool(metadata.get("requires_user_enable", True)),
|
|
575
577
|
"creates": metadata.get("creates") or [],
|
|
578
|
+
# Execution log surfacing (backlog #6): the newest of the
|
|
579
|
+
# run-now stamp vs. the latest persisted workflow run.
|
|
580
|
+
"last_execution": last_execution_view(
|
|
581
|
+
workflow, store=self._store, workspace_id=workspace_id
|
|
582
|
+
),
|
|
576
583
|
})
|
|
577
584
|
return {
|
|
578
585
|
"suggestions": suggestion_report["suggestions"],
|
|
@@ -23,9 +23,10 @@ calls a model.
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import logging
|
|
26
|
+
from datetime import datetime, timedelta
|
|
26
27
|
from typing import Any, Dict, List, Optional
|
|
27
28
|
|
|
28
|
-
from latticeai.core.timeutil import now_iso as _now
|
|
29
|
+
from latticeai.core.timeutil import local_now, now_iso as _now, parse_iso
|
|
29
30
|
|
|
30
31
|
LOGGER = logging.getLogger(__name__)
|
|
31
32
|
|
|
@@ -33,6 +34,13 @@ _RECENT_NODE_LIMIT = 6
|
|
|
33
34
|
_SEARCH_HISTORY_LIMIT = 2000
|
|
34
35
|
_BRIEFING_HISTORY_LIMIT = 2000
|
|
35
36
|
|
|
37
|
+
# Graph-hygiene advisory (review 2026-07-25 Wave 2.5): once the graph passes
|
|
38
|
+
# this many nodes with no noise-curate pass in the stale window, the briefing
|
|
39
|
+
# suggests a *dry-run* /knowledge-graph/curate/noise. Advisory only — nothing
|
|
40
|
+
# is deleted until the user explicitly applies the job.
|
|
41
|
+
_HYGIENE_MIN_NODES = 200
|
|
42
|
+
_HYGIENE_STALE_DAYS = 7
|
|
43
|
+
|
|
36
44
|
|
|
37
45
|
def _clip(text: Any, limit: int = 160) -> str:
|
|
38
46
|
value = str(text or "").strip()
|
|
@@ -147,18 +155,37 @@ class CommandCenterService:
|
|
|
147
155
|
workflows = self._workflows(workspace_id=workspace_id)
|
|
148
156
|
enabled = 0
|
|
149
157
|
drafts = 0
|
|
158
|
+
last_execution: Optional[Dict[str, Any]] = None
|
|
150
159
|
for workflow in workflows:
|
|
151
160
|
metadata = (workflow or {}).get("metadata") or {}
|
|
152
161
|
if metadata.get("automation_state") == "enabled":
|
|
153
162
|
enabled += 1
|
|
154
163
|
elif metadata.get("automation_state") == "draft_disabled":
|
|
155
164
|
drafts += 1
|
|
156
|
-
|
|
165
|
+
# Execution log surfacing (backlog #6): the briefing carries the
|
|
166
|
+
# most recent stamped automation execution so "did my automation
|
|
167
|
+
# run, and how did it go?" is answered on the home surface.
|
|
168
|
+
stamped = metadata.get("last_execution")
|
|
169
|
+
if isinstance(stamped, dict) and str(stamped.get("finished_at") or "") > str(
|
|
170
|
+
(last_execution or {}).get("finished_at") or ""
|
|
171
|
+
):
|
|
172
|
+
last_execution = {
|
|
173
|
+
"workflow_id": workflow.get("id"),
|
|
174
|
+
"name": _clip(workflow.get("name"), 120),
|
|
175
|
+
"mode": stamped.get("mode"),
|
|
176
|
+
"status": stamped.get("status"),
|
|
177
|
+
"summary": _clip(stamped.get("summary"), 200),
|
|
178
|
+
"finished_at": stamped.get("finished_at"),
|
|
179
|
+
}
|
|
180
|
+
section = {
|
|
157
181
|
"available": True,
|
|
158
182
|
"total": len(workflows),
|
|
159
183
|
"enabled": enabled,
|
|
160
184
|
"drafts": drafts,
|
|
161
185
|
}
|
|
186
|
+
if last_execution is not None:
|
|
187
|
+
section["last_execution"] = last_execution
|
|
188
|
+
return section
|
|
162
189
|
|
|
163
190
|
def _review_section(
|
|
164
191
|
self, *, user_email: Optional[str], workspace_id: Optional[str]
|
|
@@ -221,6 +248,73 @@ class CommandCenterService:
|
|
|
221
248
|
]
|
|
222
249
|
return {"available": True, "count": len(suggestions), "top": top}
|
|
223
250
|
|
|
251
|
+
def _hygiene_section(self) -> Dict[str, Any]:
|
|
252
|
+
"""Advisory graph-hygiene signal (review 2026-07-25 Wave 2.5).
|
|
253
|
+
|
|
254
|
+
Suggests a dry-run noise-curate pass when the graph has grown past
|
|
255
|
+
``_HYGIENE_MIN_NODES`` nodes and the last applied pass is missing or
|
|
256
|
+
older than ``_HYGIENE_STALE_DAYS`` days. Fail-open: any backend error
|
|
257
|
+
degrades to "no suggestion" — the briefing itself never breaks.
|
|
258
|
+
"""
|
|
259
|
+
section: Dict[str, Any] = {
|
|
260
|
+
"available": False,
|
|
261
|
+
"suggest_noise_curate": False,
|
|
262
|
+
"reason": "",
|
|
263
|
+
"last_noise_curate_at": None,
|
|
264
|
+
"node_count": 0,
|
|
265
|
+
}
|
|
266
|
+
if not self._enable_graph or not hasattr(self._kg, "stats"):
|
|
267
|
+
return section
|
|
268
|
+
try:
|
|
269
|
+
stats = self._kg.stats() or {}
|
|
270
|
+
node_count = sum(
|
|
271
|
+
int(count or 0) for count in (stats.get("nodes") or {}).values()
|
|
272
|
+
)
|
|
273
|
+
last = (
|
|
274
|
+
self._kg.last_noise_curate_at()
|
|
275
|
+
if hasattr(self._kg, "last_noise_curate_at")
|
|
276
|
+
else None
|
|
277
|
+
)
|
|
278
|
+
section.update(
|
|
279
|
+
{
|
|
280
|
+
"available": True,
|
|
281
|
+
"node_count": node_count,
|
|
282
|
+
"last_noise_curate_at": last,
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
if node_count < _HYGIENE_MIN_NODES:
|
|
286
|
+
return section
|
|
287
|
+
if last is not None and not self._older_than_days(
|
|
288
|
+
last, _HYGIENE_STALE_DAYS
|
|
289
|
+
):
|
|
290
|
+
return section
|
|
291
|
+
section["suggest_noise_curate"] = True
|
|
292
|
+
section["reason"] = (
|
|
293
|
+
f"{node_count} nodes and no noise curation in the last "
|
|
294
|
+
f"{_HYGIENE_STALE_DAYS} days"
|
|
295
|
+
if last
|
|
296
|
+
else f"{node_count} nodes and no noise curation recorded"
|
|
297
|
+
)
|
|
298
|
+
except Exception:
|
|
299
|
+
LOGGER.exception("command center hygiene read failed")
|
|
300
|
+
return {
|
|
301
|
+
"available": False,
|
|
302
|
+
"suggest_noise_curate": False,
|
|
303
|
+
"reason": "",
|
|
304
|
+
"last_noise_curate_at": None,
|
|
305
|
+
"node_count": 0,
|
|
306
|
+
}
|
|
307
|
+
return section
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _older_than_days(stamp: str, days: int) -> bool:
|
|
311
|
+
parsed = parse_iso(stamp)
|
|
312
|
+
if parsed is None:
|
|
313
|
+
# Unreadable stamp → treat as stale; suggesting a dry-run is safe.
|
|
314
|
+
return True
|
|
315
|
+
now = datetime.now(parsed.tzinfo) if parsed.tzinfo else local_now()
|
|
316
|
+
return (now - parsed) > timedelta(days=days)
|
|
317
|
+
|
|
224
318
|
# ── quick actions ────────────────────────────────────────────────────
|
|
225
319
|
|
|
226
320
|
def _quick_actions(self, sections: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
@@ -276,6 +370,19 @@ class CommandCenterService:
|
|
|
276
370
|
"target": "/brain/graph",
|
|
277
371
|
}
|
|
278
372
|
)
|
|
373
|
+
hygiene = sections.get("hygiene") or {}
|
|
374
|
+
if hygiene.get("suggest_noise_curate"):
|
|
375
|
+
actions.append(
|
|
376
|
+
{
|
|
377
|
+
"id": "curate-noise",
|
|
378
|
+
"kind": "hygiene",
|
|
379
|
+
"count": int(hygiene.get("node_count") or 0),
|
|
380
|
+
"target": "/brain/graph",
|
|
381
|
+
# Dry-run by default on the API side: this endpoint only
|
|
382
|
+
# *reports* removals until dry_run=false is sent explicitly.
|
|
383
|
+
"endpoint": "/knowledge-graph/curate/noise",
|
|
384
|
+
}
|
|
385
|
+
)
|
|
279
386
|
if not actions:
|
|
280
387
|
actions.append(
|
|
281
388
|
{"id": "ask-brain", "kind": "chat", "count": 0, "target": "/brain"}
|
|
@@ -300,6 +407,7 @@ class CommandCenterService:
|
|
|
300
407
|
"suggestions": self._suggestion_section(
|
|
301
408
|
user_email=user_email, workspace_id=workspace_id
|
|
302
409
|
),
|
|
410
|
+
"hygiene": self._hygiene_section(),
|
|
303
411
|
}
|
|
304
412
|
return {
|
|
305
413
|
"generated_at": _now(),
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Opt-in folder watch mode for the unified ingestion pipeline (backlog #8).
|
|
2
|
+
|
|
3
|
+
Watches previously-ingested folders and incrementally re-ingests new/changed
|
|
4
|
+
files through the normal :class:`IngestionPipeline` door (same filters, same
|
|
5
|
+
quality pipeline, same workspace scoping, same provenance).
|
|
6
|
+
|
|
7
|
+
Design decisions (review §7.2 B + risk section):
|
|
8
|
+
|
|
9
|
+
* **Default OFF, explicit opt-in.** A watch exists only after an explicit API
|
|
10
|
+
call. The stored config is the durable record of that consent; ``restore()``
|
|
11
|
+
resumes only entries persisted with ``enabled: true`` and never invents one.
|
|
12
|
+
* **Polling with mtime snapshots**, not an OS watcher. ``watchdog`` is already
|
|
13
|
+
a dependency, but polling is deterministic, portable, and testable
|
|
14
|
+
(``scan_once`` runs synchronously); the interval is configurable via
|
|
15
|
+
``LATTICEAI_FOLDER_WATCH_INTERVAL`` (seconds, default 30).
|
|
16
|
+
* **Reuses the folder-ingest filters** (skip dirs, ``.latticeignore``,
|
|
17
|
+
extension allow-list, size cap) so watch mode can never ingest something a
|
|
18
|
+
manual folder ingest would have refused.
|
|
19
|
+
* Deleted files are *counted* but their graph nodes are not removed — node
|
|
20
|
+
deletion is a destructive operation that stays behind explicit user flows.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import threading
|
|
28
|
+
import uuid
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
from lattice_brain.ingestion import (
|
|
33
|
+
DEFAULT_FOLDER_EXTENSIONS,
|
|
34
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
35
|
+
FOLDER_DEFAULT_SKIP_DIRS,
|
|
36
|
+
FOLDER_DOCUMENT_EXTENSIONS,
|
|
37
|
+
LATTICEIGNORE_FILENAME,
|
|
38
|
+
IngestionItem,
|
|
39
|
+
_load_latticeignore,
|
|
40
|
+
_matches_ignore,
|
|
41
|
+
)
|
|
42
|
+
from latticeai.core.timeutil import now_iso as _now_iso
|
|
43
|
+
|
|
44
|
+
WATCH_INTERVAL_ENV = "LATTICEAI_FOLDER_WATCH_INTERVAL"
|
|
45
|
+
DEFAULT_WATCH_INTERVAL_SECONDS = 30.0
|
|
46
|
+
MAX_FILES_PER_SCAN = 200 # per-watch per-scan ingest cap (thrash guard)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _default_interval() -> float:
|
|
50
|
+
raw = os.getenv(WATCH_INTERVAL_ENV, "").strip()
|
|
51
|
+
try:
|
|
52
|
+
value = float(raw) if raw else DEFAULT_WATCH_INTERVAL_SECONDS
|
|
53
|
+
except ValueError:
|
|
54
|
+
value = DEFAULT_WATCH_INTERVAL_SECONDS
|
|
55
|
+
return max(1.0, value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FolderWatchService:
|
|
59
|
+
"""Poll-based incremental re-ingestion of explicitly opted-in folders."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
pipeline: Any,
|
|
65
|
+
config_path: Path,
|
|
66
|
+
interval_seconds: Optional[float] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._pipeline = pipeline
|
|
69
|
+
self._config_path = Path(config_path)
|
|
70
|
+
self._interval = float(interval_seconds) if interval_seconds else _default_interval()
|
|
71
|
+
self._lock = threading.Lock()
|
|
72
|
+
self._stop = threading.Event()
|
|
73
|
+
self._thread: Optional[threading.Thread] = None
|
|
74
|
+
self._watches: Dict[str, Dict[str, Any]] = self._load_config()
|
|
75
|
+
|
|
76
|
+
# ── persistence ──────────────────────────────────────────────────────────
|
|
77
|
+
def _load_config(self) -> Dict[str, Dict[str, Any]]:
|
|
78
|
+
try:
|
|
79
|
+
payload = json.loads(self._config_path.read_text(encoding="utf-8"))
|
|
80
|
+
except (OSError, ValueError):
|
|
81
|
+
return {}
|
|
82
|
+
watches = payload.get("watches")
|
|
83
|
+
return dict(watches) if isinstance(watches, dict) else {}
|
|
84
|
+
|
|
85
|
+
def _save_config(self) -> None:
|
|
86
|
+
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
tmp = self._config_path.with_suffix(".tmp")
|
|
88
|
+
tmp.write_text(
|
|
89
|
+
json.dumps({"watches": self._watches}, ensure_ascii=False, indent=2),
|
|
90
|
+
encoding="utf-8",
|
|
91
|
+
)
|
|
92
|
+
tmp.replace(self._config_path)
|
|
93
|
+
|
|
94
|
+
# ── public API ───────────────────────────────────────────────────────────
|
|
95
|
+
def enable(
|
|
96
|
+
self,
|
|
97
|
+
path: Any,
|
|
98
|
+
*,
|
|
99
|
+
owner: Optional[str] = None,
|
|
100
|
+
workspace_id: Optional[str] = None,
|
|
101
|
+
recursive: bool = True,
|
|
102
|
+
) -> Dict[str, Any]:
|
|
103
|
+
"""Explicit opt-in: snapshot the folder now, ingest only future changes.
|
|
104
|
+
|
|
105
|
+
The folder is assumed to have been ingested already (the UI offers
|
|
106
|
+
watch after a folder ingest); the enable-time snapshot is the baseline,
|
|
107
|
+
so enabling never re-ingests the whole folder.
|
|
108
|
+
"""
|
|
109
|
+
root = Path(path).expanduser()
|
|
110
|
+
if not root.is_dir():
|
|
111
|
+
return {"status": "failed", "detail": f"not a directory: {root}"}
|
|
112
|
+
root = root.resolve()
|
|
113
|
+
with self._lock:
|
|
114
|
+
for watch in self._watches.values():
|
|
115
|
+
same_scope = (watch.get("workspace_id") or None) == (workspace_id or None)
|
|
116
|
+
if watch.get("path") == str(root) and same_scope:
|
|
117
|
+
watch["enabled"] = True
|
|
118
|
+
watch["snapshot"] = self._snapshot(root, recursive=bool(watch.get("recursive", True)))
|
|
119
|
+
self._save_config()
|
|
120
|
+
self._ensure_thread()
|
|
121
|
+
return {"status": "ok", "watch": self._public_watch(watch), "already_watching": True}
|
|
122
|
+
watch_id = f"watch_{uuid.uuid4().hex[:12]}"
|
|
123
|
+
watch = {
|
|
124
|
+
"id": watch_id,
|
|
125
|
+
"path": str(root),
|
|
126
|
+
"owner": owner,
|
|
127
|
+
"workspace_id": workspace_id,
|
|
128
|
+
"recursive": bool(recursive),
|
|
129
|
+
"enabled": True,
|
|
130
|
+
"created_at": _now_iso(),
|
|
131
|
+
"last_scan_at": None,
|
|
132
|
+
"last_result": None,
|
|
133
|
+
"snapshot": self._snapshot(root, recursive=bool(recursive)),
|
|
134
|
+
}
|
|
135
|
+
self._watches[watch_id] = watch
|
|
136
|
+
self._save_config()
|
|
137
|
+
self._ensure_thread()
|
|
138
|
+
return {"status": "ok", "watch": self._public_watch(watch), "already_watching": False}
|
|
139
|
+
|
|
140
|
+
def disable(
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
watch_id: Optional[str] = None,
|
|
144
|
+
path: Optional[str] = None,
|
|
145
|
+
) -> Dict[str, Any]:
|
|
146
|
+
"""Opt back out: remove the stored consent record entirely."""
|
|
147
|
+
with self._lock:
|
|
148
|
+
target_id = None
|
|
149
|
+
if watch_id and watch_id in self._watches:
|
|
150
|
+
target_id = watch_id
|
|
151
|
+
elif path:
|
|
152
|
+
wanted = str(Path(path).expanduser().resolve())
|
|
153
|
+
for wid, watch in self._watches.items():
|
|
154
|
+
if watch.get("path") == wanted:
|
|
155
|
+
target_id = wid
|
|
156
|
+
break
|
|
157
|
+
if target_id is None:
|
|
158
|
+
return {"status": "not_found"}
|
|
159
|
+
removed = self._watches.pop(target_id)
|
|
160
|
+
self._save_config()
|
|
161
|
+
if not any(w.get("enabled") for w in self._watches.values()):
|
|
162
|
+
self._stop_thread_locked()
|
|
163
|
+
return {"status": "ok", "watch": self._public_watch(removed)}
|
|
164
|
+
|
|
165
|
+
def status(self) -> Dict[str, Any]:
|
|
166
|
+
with self._lock:
|
|
167
|
+
watches = [self._public_watch(w) for w in self._watches.values()]
|
|
168
|
+
polling = self._thread is not None and self._thread.is_alive()
|
|
169
|
+
return {
|
|
170
|
+
"enabled_count": sum(1 for w in watches if w.get("enabled")),
|
|
171
|
+
"polling": polling,
|
|
172
|
+
"interval_seconds": self._interval,
|
|
173
|
+
"watches": watches,
|
|
174
|
+
"note": (
|
|
175
|
+
"Watch mode is opt-in and off by default. Deleted files are "
|
|
176
|
+
"counted but never auto-removed from the Brain."
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def restore(self) -> Dict[str, Any]:
|
|
181
|
+
"""Resume polling for watches persisted with the explicit opt-in.
|
|
182
|
+
|
|
183
|
+
Never starts anything when the stored config has no enabled entries —
|
|
184
|
+
an empty/missing config means the user never opted in.
|
|
185
|
+
"""
|
|
186
|
+
with self._lock:
|
|
187
|
+
enabled = [w for w in self._watches.values() if w.get("enabled")]
|
|
188
|
+
if enabled:
|
|
189
|
+
self._ensure_thread()
|
|
190
|
+
return {"restored": len(enabled), "polling": bool(enabled)}
|
|
191
|
+
|
|
192
|
+
def stop_all(self) -> None:
|
|
193
|
+
"""Stop the polling thread (keeps the stored opt-in for next start)."""
|
|
194
|
+
with self._lock:
|
|
195
|
+
self._stop_thread_locked()
|
|
196
|
+
|
|
197
|
+
def scan_once(self, watch_id: str) -> Dict[str, Any]:
|
|
198
|
+
"""Synchronously scan one watch and ingest new/changed files."""
|
|
199
|
+
with self._lock:
|
|
200
|
+
watch = self._watches.get(watch_id)
|
|
201
|
+
if watch is None:
|
|
202
|
+
return {"status": "not_found", "watch_id": watch_id}
|
|
203
|
+
if not watch.get("enabled"):
|
|
204
|
+
return {"status": "disabled", "watch_id": watch_id}
|
|
205
|
+
snapshot = dict(watch.get("snapshot") or {})
|
|
206
|
+
root = Path(watch["path"])
|
|
207
|
+
recursive = bool(watch.get("recursive", True))
|
|
208
|
+
owner = watch.get("owner")
|
|
209
|
+
workspace_id = watch.get("workspace_id")
|
|
210
|
+
|
|
211
|
+
result: Dict[str, Any] = {
|
|
212
|
+
"status": "ok",
|
|
213
|
+
"watch_id": watch_id,
|
|
214
|
+
"new": 0,
|
|
215
|
+
"changed": 0,
|
|
216
|
+
"removed": 0,
|
|
217
|
+
"ingested": 0,
|
|
218
|
+
"duplicate": 0,
|
|
219
|
+
"failed": 0,
|
|
220
|
+
"errors": [],
|
|
221
|
+
}
|
|
222
|
+
if not root.is_dir():
|
|
223
|
+
result.update(status="failed", detail=f"folder unavailable: {root}")
|
|
224
|
+
self._record_scan(watch_id, result, snapshot=None)
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
current = self._snapshot(root, recursive=recursive)
|
|
228
|
+
changed_rel: List[str] = []
|
|
229
|
+
for rel, stamp in current.items():
|
|
230
|
+
previous = snapshot.get(rel)
|
|
231
|
+
if previous is None:
|
|
232
|
+
result["new"] += 1
|
|
233
|
+
changed_rel.append(rel)
|
|
234
|
+
elif previous != stamp:
|
|
235
|
+
result["changed"] += 1
|
|
236
|
+
changed_rel.append(rel)
|
|
237
|
+
result["removed"] = sum(1 for rel in snapshot if rel not in current)
|
|
238
|
+
|
|
239
|
+
for rel in changed_rel[:MAX_FILES_PER_SCAN]:
|
|
240
|
+
path = root / rel
|
|
241
|
+
ext = path.suffix.lower()
|
|
242
|
+
metadata: Dict[str, Any] = {
|
|
243
|
+
"relative_path": rel,
|
|
244
|
+
"folder_watch": True,
|
|
245
|
+
"watch_id": watch_id,
|
|
246
|
+
}
|
|
247
|
+
if ext in FOLDER_DOCUMENT_EXTENSIONS:
|
|
248
|
+
source_type = "pdf"
|
|
249
|
+
else:
|
|
250
|
+
source_type = "file"
|
|
251
|
+
try:
|
|
252
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
253
|
+
except OSError as exc:
|
|
254
|
+
result["failed"] += 1
|
|
255
|
+
if len(result["errors"]) < 20:
|
|
256
|
+
result["errors"].append({"path": rel, "detail": f"read failed: {exc}"})
|
|
257
|
+
continue
|
|
258
|
+
metadata["extracted"] = {"content": content, "chars": len(content)}
|
|
259
|
+
outcome = self._pipeline.ingest(
|
|
260
|
+
IngestionItem(
|
|
261
|
+
source_type=source_type,
|
|
262
|
+
title=path.name,
|
|
263
|
+
path=str(path),
|
|
264
|
+
source_uri=str(path),
|
|
265
|
+
owner=owner,
|
|
266
|
+
workspace_id=workspace_id,
|
|
267
|
+
metadata=metadata,
|
|
268
|
+
),
|
|
269
|
+
user_email=owner,
|
|
270
|
+
)
|
|
271
|
+
if outcome.status == "ok":
|
|
272
|
+
if outcome.duplicate:
|
|
273
|
+
result["duplicate"] += 1
|
|
274
|
+
else:
|
|
275
|
+
result["ingested"] += 1
|
|
276
|
+
else:
|
|
277
|
+
result["failed"] += 1
|
|
278
|
+
if len(result["errors"]) < 20:
|
|
279
|
+
result["errors"].append({
|
|
280
|
+
"path": rel,
|
|
281
|
+
"detail": outcome.detail or outcome.status,
|
|
282
|
+
})
|
|
283
|
+
if len(changed_rel) > MAX_FILES_PER_SCAN:
|
|
284
|
+
result["truncated"] = True
|
|
285
|
+
self._record_scan(watch_id, result, snapshot=current)
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
289
|
+
def _record_scan(
|
|
290
|
+
self,
|
|
291
|
+
watch_id: str,
|
|
292
|
+
result: Dict[str, Any],
|
|
293
|
+
*,
|
|
294
|
+
snapshot: Optional[Dict[str, Any]],
|
|
295
|
+
) -> None:
|
|
296
|
+
with self._lock:
|
|
297
|
+
watch = self._watches.get(watch_id)
|
|
298
|
+
if watch is None:
|
|
299
|
+
return
|
|
300
|
+
watch["last_scan_at"] = _now_iso()
|
|
301
|
+
watch["last_result"] = {
|
|
302
|
+
key: result[key]
|
|
303
|
+
for key in ("status", "new", "changed", "removed", "ingested", "duplicate", "failed")
|
|
304
|
+
if key in result
|
|
305
|
+
}
|
|
306
|
+
# Watch trust UI (review Wave 1.3): keep a small sample of the
|
|
307
|
+
# latest scan's failures so the home health card can show *why*
|
|
308
|
+
# a watch is unhealthy, not just a count.
|
|
309
|
+
watch["last_errors"] = list(result.get("errors") or [])[:3]
|
|
310
|
+
if snapshot is not None:
|
|
311
|
+
watch["snapshot"] = snapshot
|
|
312
|
+
self._save_config()
|
|
313
|
+
|
|
314
|
+
def _snapshot(self, root: Path, *, recursive: bool) -> Dict[str, List[float]]:
|
|
315
|
+
"""{relative_posix_path: [mtime, size]} using the folder-ingest filters."""
|
|
316
|
+
patterns = _load_latticeignore(root)
|
|
317
|
+
snapshot: Dict[str, List[float]] = {}
|
|
318
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
319
|
+
current = Path(dirpath)
|
|
320
|
+
rel_dir = current.relative_to(root)
|
|
321
|
+
kept: List[str] = []
|
|
322
|
+
for name in sorted(dirnames):
|
|
323
|
+
if name in FOLDER_DEFAULT_SKIP_DIRS or name.startswith("."):
|
|
324
|
+
continue
|
|
325
|
+
rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
|
|
326
|
+
if _matches_ignore(rel, name, is_dir=True, patterns=patterns):
|
|
327
|
+
continue
|
|
328
|
+
kept.append(name)
|
|
329
|
+
dirnames[:] = kept if recursive else []
|
|
330
|
+
for name in sorted(filenames):
|
|
331
|
+
if name == LATTICEIGNORE_FILENAME or name.startswith("."):
|
|
332
|
+
continue
|
|
333
|
+
path = current / name
|
|
334
|
+
rel = name if str(rel_dir) == "." else (rel_dir / name).as_posix()
|
|
335
|
+
if _matches_ignore(rel, name, is_dir=False, patterns=patterns):
|
|
336
|
+
continue
|
|
337
|
+
if path.suffix.lower() not in DEFAULT_FOLDER_EXTENSIONS:
|
|
338
|
+
continue
|
|
339
|
+
try:
|
|
340
|
+
stat = path.stat()
|
|
341
|
+
except OSError:
|
|
342
|
+
continue
|
|
343
|
+
if stat.st_size > DEFAULT_MAX_FILE_BYTES:
|
|
344
|
+
continue
|
|
345
|
+
snapshot[rel] = [round(stat.st_mtime, 3), stat.st_size]
|
|
346
|
+
return snapshot
|
|
347
|
+
|
|
348
|
+
@staticmethod
|
|
349
|
+
def _public_watch(watch: Dict[str, Any]) -> Dict[str, Any]:
|
|
350
|
+
return {
|
|
351
|
+
"id": watch.get("id"),
|
|
352
|
+
"path": watch.get("path"),
|
|
353
|
+
"owner": watch.get("owner"),
|
|
354
|
+
"workspace_id": watch.get("workspace_id"),
|
|
355
|
+
"recursive": bool(watch.get("recursive", True)),
|
|
356
|
+
"enabled": bool(watch.get("enabled")),
|
|
357
|
+
"created_at": watch.get("created_at"),
|
|
358
|
+
"last_scan_at": watch.get("last_scan_at"),
|
|
359
|
+
"last_result": watch.get("last_result"),
|
|
360
|
+
"last_errors": list(watch.get("last_errors") or []),
|
|
361
|
+
"tracked_files": len(watch.get("snapshot") or {}),
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
def _ensure_thread(self) -> None:
|
|
365
|
+
"""Start the poller (caller holds the lock)."""
|
|
366
|
+
if self._thread is not None and self._thread.is_alive():
|
|
367
|
+
return
|
|
368
|
+
self._stop = threading.Event()
|
|
369
|
+
self._thread = threading.Thread(
|
|
370
|
+
target=self._poll_loop, name="folder-watch-poller", daemon=True,
|
|
371
|
+
)
|
|
372
|
+
self._thread.start()
|
|
373
|
+
|
|
374
|
+
def _stop_thread_locked(self) -> None:
|
|
375
|
+
self._stop.set()
|
|
376
|
+
self._thread = None
|
|
377
|
+
|
|
378
|
+
def _poll_loop(self) -> None:
|
|
379
|
+
stop = self._stop
|
|
380
|
+
while not stop.wait(self._interval):
|
|
381
|
+
with self._lock:
|
|
382
|
+
enabled_ids = [
|
|
383
|
+
wid for wid, watch in self._watches.items() if watch.get("enabled")
|
|
384
|
+
]
|
|
385
|
+
if not enabled_ids:
|
|
386
|
+
return
|
|
387
|
+
for watch_id in enabled_ids:
|
|
388
|
+
if stop.is_set():
|
|
389
|
+
return
|
|
390
|
+
try:
|
|
391
|
+
self.scan_once(watch_id)
|
|
392
|
+
except Exception: # noqa: BLE001 — one bad watch must not kill the poller
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
__all__ = ["FolderWatchService", "DEFAULT_WATCH_INTERVAL_SECONDS", "WATCH_INTERVAL_ENV"]
|