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.
Files changed (109) hide show
  1. package/README.md +44 -39
  2. package/docs/CHANGELOG.md +109 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +47 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/curator.py +141 -0
  14. package/lattice_brain/graph/discovery_index.py +8 -1
  15. package/lattice_brain/graph/documents.py +111 -0
  16. package/lattice_brain/graph/fusion.py +179 -0
  17. package/lattice_brain/graph/ingest.py +43 -9
  18. package/lattice_brain/graph/projection.py +382 -37
  19. package/lattice_brain/graph/retrieval.py +71 -4
  20. package/lattice_brain/graph/retrieval_policy.py +174 -0
  21. package/lattice_brain/graph/retrieval_vector.py +122 -2
  22. package/lattice_brain/ingestion.py +66 -0
  23. package/lattice_brain/portability.py +41 -12
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/automation_intelligence.py +159 -0
  27. package/latticeai/api/browser.py +15 -4
  28. package/latticeai/api/chat.py +33 -2
  29. package/latticeai/api/chat_agent_http.py +363 -6
  30. package/latticeai/api/chat_contracts.py +7 -1
  31. package/latticeai/api/chat_helpers.py +147 -0
  32. package/latticeai/api/chat_intents.py +217 -10
  33. package/latticeai/api/chat_stream.py +93 -3
  34. package/latticeai/api/funnel_metrics.py +31 -0
  35. package/latticeai/api/knowledge_graph.py +65 -1
  36. package/latticeai/api/local_files.py +90 -0
  37. package/latticeai/api/setup.py +134 -1
  38. package/latticeai/api/tools.py +33 -3
  39. package/latticeai/app_factory.py +14 -0
  40. package/latticeai/core/agent.py +355 -18
  41. package/latticeai/core/agent_eval.py +107 -0
  42. package/latticeai/core/enterprise.py +5 -0
  43. package/latticeai/core/file_generation.py +404 -2
  44. package/latticeai/core/legacy_compatibility.py +1 -1
  45. package/latticeai/core/marketplace.py +1 -1
  46. package/latticeai/core/run_store.py +243 -0
  47. package/latticeai/core/workspace_os.py +1 -1
  48. package/latticeai/models/router.py +25 -12
  49. package/latticeai/runtime/persistence_runtime.py +19 -1
  50. package/latticeai/runtime/router_registration.py +8 -1
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/automation_execution.py +266 -0
  54. package/latticeai/services/automation_intelligence.py +7 -0
  55. package/latticeai/services/command_center.py +110 -2
  56. package/latticeai/services/folder_watch.py +396 -0
  57. package/latticeai/services/funnel_metrics.py +210 -0
  58. package/latticeai/services/product_readiness.py +1 -1
  59. package/latticeai/services/search_service.py +51 -5
  60. package/latticeai/setup/demo_corpus.py +98 -0
  61. package/latticeai/tools/filesystem.py +38 -1
  62. package/package.json +1 -1
  63. package/scripts/bench_agent_smoke.py +410 -0
  64. package/scripts/bench_models.py +234 -5
  65. package/scripts/check_current_release_docs.mjs +1 -1
  66. package/scripts/funnel_soft_gate.py +192 -0
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +29 -27
  71. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  72. package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
  73. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  74. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  75. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
  76. package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
  77. package/static/app/assets/Library-pKCK0_tk.js +1 -0
  78. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
  79. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
  80. package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
  81. package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
  82. package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
  83. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  84. package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
  85. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
  86. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
  87. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
  88. package/static/app/assets/index-7FAfYm4v.css +2 -0
  89. package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
  90. package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
  91. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
  92. package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
  93. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
  94. package/static/app/assets/shield-alert-BftATuAA.js +1 -0
  95. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
  96. package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
  97. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  98. package/static/app/index.html +4 -4
  99. package/static/sw.js +1 -1
  100. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  101. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  102. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  103. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  104. package/static/app/assets/Library-PcfVUco6.js +0 -1
  105. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  106. package/static/app/assets/download-C7yxYHvz.js +0 -1
  107. package/static/app/assets/index-DU78Kogz.css +0 -2
  108. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  109. 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
- return {
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"]