ltcai 9.7.0 → 9.9.0

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 (85) hide show
  1. package/README.md +114 -320
  2. package/docs/BENCHMARKS.md +107 -0
  3. package/docs/CHANGELOG.md +72 -0
  4. package/docs/CI_AND_RELEASE_GATES.md +106 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  6. package/docs/DEVELOPMENT.md +11 -8
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +15 -6
  9. package/docs/PERFORMANCE.md +9 -1
  10. package/docs/SECURITY_AUDIT.md +91 -0
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/USABILITY_AUDIT.md +164 -0
  13. package/docs/WHY_LATTICE.md +1 -1
  14. package/docs/architecture.md +4 -2
  15. package/docs/kg-schema.md +1 -1
  16. package/docs/spec-vs-impl.md +8 -1
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/graph/retrieval.py +93 -4
  19. package/lattice_brain/graph/retrieval_vector.py +43 -0
  20. package/lattice_brain/ingestion.py +399 -14
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +12 -0
  24. package/latticeai/api/chat.py +17 -0
  25. package/latticeai/api/chat_helpers.py +48 -0
  26. package/latticeai/api/chat_stream.py +9 -1
  27. package/latticeai/api/local_files.py +120 -2
  28. package/latticeai/api/review_queue.py +7 -0
  29. package/latticeai/core/agent.py +105 -8
  30. package/latticeai/core/agent_eval.py +222 -1
  31. package/latticeai/core/legacy_compatibility.py +1 -1
  32. package/latticeai/core/marketplace.py +1 -1
  33. package/latticeai/core/tool_governor.py +92 -1
  34. package/latticeai/core/workspace_os.py +1 -1
  35. package/latticeai/services/architecture_readiness.py +1 -1
  36. package/latticeai/services/automation_intelligence.py +151 -14
  37. package/latticeai/services/brain_intelligence.py +72 -0
  38. package/latticeai/services/change_proposals.py +165 -23
  39. package/latticeai/services/product_readiness.py +1 -1
  40. package/latticeai/services/tool_dispatch.py +33 -0
  41. package/package.json +5 -3
  42. package/scripts/bench_models.py +312 -0
  43. package/scripts/check_bundle_budget.mjs +103 -0
  44. package/scripts/check_current_release_docs.mjs +1 -1
  45. package/scripts/check_doc_status.mjs +149 -0
  46. package/scripts/generate_sbom.py +70 -0
  47. package/src-tauri/Cargo.lock +1 -1
  48. package/src-tauri/Cargo.toml +1 -1
  49. package/src-tauri/tauri.conf.json +1 -1
  50. package/static/app/asset-manifest.json +27 -11
  51. package/static/app/assets/Act-CfOPUKPs.js +2 -0
  52. package/static/app/assets/Brain-Cgkh0Hpn.js +321 -0
  53. package/static/app/assets/BrainHome-D8e3wQiW.js +3 -0
  54. package/static/app/assets/BrainSignals-BjRBA18L.js +1 -0
  55. package/static/app/assets/Capture-CQQYQ1Ga.js +1 -0
  56. package/static/app/assets/CommandPalette-pss56Mg4.js +1 -0
  57. package/static/app/assets/Library-CoI3xYJk.js +1 -0
  58. package/static/app/assets/LivingBrain-CaU_hCxQ.js +1 -0
  59. package/static/app/assets/ProductFlow-Db-1O71-.js +1 -0
  60. package/static/app/assets/System-Dfocn0zN.js +1 -0
  61. package/static/app/assets/bot-Bp2x1i6F.js +1 -0
  62. package/static/app/assets/circle-check-CyGal42W.js +1 -0
  63. package/static/app/assets/cpu-DQFk82hG.js +1 -0
  64. package/static/app/assets/download-C70gKQVi.js +1 -0
  65. package/static/app/assets/folder-open-DNGytsVw.js +1 -0
  66. package/static/app/assets/hard-drive-CYo_bEl4.js +1 -0
  67. package/static/app/assets/i18n-BBMJshCW.js +7 -0
  68. package/static/app/assets/index-BjaXCeOZ.js +10 -0
  69. package/static/app/assets/{index-85wQvEie.css → index-BqRcLZR3.css} +1 -1
  70. package/static/app/assets/input-ChWD-Fsh.js +1 -0
  71. package/static/app/assets/navigation-D9D9_FPC.js +1 -0
  72. package/static/app/assets/network-DzLDGmtV.js +1 -0
  73. package/static/app/assets/primitives-CmaSAyGG.js +1 -0
  74. package/static/app/assets/sparkles-DXiaM1NS.js +1 -0
  75. package/static/app/assets/textarea-CV43-o79.js +1 -0
  76. package/static/app/index.html +4 -2
  77. package/static/sw.js +1 -1
  78. package/static/app/assets/Act-B6c39ays.js +0 -2
  79. package/static/app/assets/Brain-D7Qg4k6M.js +0 -321
  80. package/static/app/assets/Capture-VF_di68r.js +0 -1
  81. package/static/app/assets/Library-D_Gis2PA.js +0 -1
  82. package/static/app/assets/System-C5s5H2ov.js +0 -1
  83. package/static/app/assets/index-DJC_2oub.js +0 -18
  84. package/static/app/assets/primitives-DL4Nip8C.js +0 -1
  85. package/static/app/assets/textarea-woZfCXHy.js +0 -1
@@ -44,6 +44,63 @@ _ADDITIVE_TOOLS = frozenset({
44
44
  "knowledge_graph_ingest",
45
45
  })
46
46
 
47
+ # ── Mutating-tool inventory (single source of truth) ─────────────────────
48
+ # Every tool with a side effect is classified into exactly one category so a
49
+ # CI check can prove nothing slips through ungoverned. Categories:
50
+ # new_artifact — only ever creates new content (additive)
51
+ # existing_content_update — can rewrite content that already exists
52
+ # delete — removes existing content
53
+ # external_side_effect — acts outside the Brain (shell, deploy, desktop, net)
54
+ # internal_state — agent-internal bookkeeping, not user content
55
+ NEW_ARTIFACT = "new_artifact"
56
+ EXISTING_CONTENT_UPDATE = "existing_content_update"
57
+ DELETE = "delete"
58
+ EXTERNAL_SIDE_EFFECT = "external_side_effect"
59
+ INTERNAL_STATE = "internal_state"
60
+
61
+ MUTATING_TOOL_INVENTORY: Dict[str, str] = {
62
+ # text files — fully proposal-capable (staged + applied as reviewed)
63
+ "write_file": EXISTING_CONTENT_UPDATE,
64
+ "edit_file": EXISTING_CONTENT_UPDATE,
65
+ # binary/document creators — can overwrite an existing file, but their
66
+ # output cannot be staged as a text diff, so an overwrite is fail-closed.
67
+ "create_docx": EXISTING_CONTENT_UPDATE,
68
+ "create_xlsx": EXISTING_CONTENT_UPDATE,
69
+ "create_pptx": EXISTING_CONTENT_UPDATE,
70
+ "create_pdf": EXISTING_CONTENT_UPDATE,
71
+ # home-sandbox write — same story: overwrite is fail-closed until the
72
+ # proposal service learns to stage home-sandbox paths.
73
+ "local_write": EXISTING_CONTENT_UPDATE,
74
+ # additive-only writes
75
+ "create_web_project": NEW_ARTIFACT,
76
+ "knowledge_save": NEW_ARTIFACT,
77
+ "obsidian_save": NEW_ARTIFACT,
78
+ "knowledge_graph_ingest": NEW_ARTIFACT,
79
+ # agent-internal state
80
+ "todo_write": INTERNAL_STATE,
81
+ # deletions
82
+ "delete_file": DELETE,
83
+ "remove_file": DELETE,
84
+ "clear_history": DELETE,
85
+ # external side effects (approval-gated, never proposal-based)
86
+ "run_command": EXTERNAL_SIDE_EFFECT,
87
+ "build_project": EXTERNAL_SIDE_EFFECT,
88
+ "deploy_project": EXTERNAL_SIDE_EFFECT,
89
+ "computer_click": EXTERNAL_SIDE_EFFECT,
90
+ "computer_type": EXTERNAL_SIDE_EFFECT,
91
+ "computer_key": EXTERNAL_SIDE_EFFECT,
92
+ "computer_scroll": EXTERNAL_SIDE_EFFECT,
93
+ "computer_drag": EXTERNAL_SIDE_EFFECT,
94
+ "computer_move": EXTERNAL_SIDE_EFFECT,
95
+ "computer_open_app": EXTERNAL_SIDE_EFFECT,
96
+ "computer_open_url": EXTERNAL_SIDE_EFFECT,
97
+ }
98
+
99
+ # Tools whose existing-content update the ChangeProposalService can actually
100
+ # stage AND apply as a reviewed proposal. A tool that is proposal_required but
101
+ # NOT here is fail-closed (blocked) rather than silently applied.
102
+ PROPOSAL_CAPABLE_TOOLS = frozenset({"write_file", "edit_file"})
103
+
47
104
 
48
105
  def classify_tool_call(
49
106
  name: str,
@@ -83,19 +140,53 @@ def classify_tool_call(
83
140
  change = CHANGE_ADDITIVE
84
141
  reason = "write tool without an existing target"
85
142
 
143
+ proposal_required = change in {CHANGE_MUTATION, CHANGE_DESTRUCTIVE}
144
+ proposal_supported = name in PROPOSAL_CAPABLE_TOOLS
145
+ # A change that must be reviewed as a proposal but that we cannot stage is
146
+ # fail-closed: callers must block it instead of applying it silently.
147
+ fail_closed = proposal_required and not proposal_supported
148
+
86
149
  return {
87
150
  "tool": name,
88
151
  "change_class": change,
89
- "proposal_required": change in {CHANGE_MUTATION, CHANGE_DESTRUCTIVE},
152
+ "proposal_required": proposal_required,
153
+ "proposal_supported": proposal_supported,
154
+ "fail_closed": fail_closed,
90
155
  "reason": reason,
91
156
  }
92
157
 
93
158
 
159
+ def assert_governance_coverage(tool_names: "Any") -> None:
160
+ """Raise if any side-effecting registry tool is not classified.
161
+
162
+ A new mutating tool added to the registry without an inventory entry makes
163
+ this raise, so CI fails closed instead of shipping an ungoverned mutator.
164
+ Read-only tools (risk ``read``) are intentionally exempt.
165
+ """
166
+ missing = [
167
+ name for name in tool_names
168
+ if name not in MUTATING_TOOL_INVENTORY
169
+ ]
170
+ if missing:
171
+ raise ValueError(
172
+ "ungoverned mutating tools (add to MUTATING_TOOL_INVENTORY): "
173
+ + ", ".join(sorted(missing))
174
+ )
175
+
176
+
94
177
  __all__ = [
95
178
  "CHANGE_READ",
96
179
  "CHANGE_ADDITIVE",
97
180
  "CHANGE_MUTATION",
98
181
  "CHANGE_DESTRUCTIVE",
99
182
  "CHANGE_EXEC",
183
+ "NEW_ARTIFACT",
184
+ "EXISTING_CONTENT_UPDATE",
185
+ "DELETE",
186
+ "EXTERNAL_SIDE_EFFECT",
187
+ "INTERNAL_STATE",
188
+ "MUTATING_TOOL_INVENTORY",
189
+ "PROPOSAL_CAPABLE_TOOLS",
100
190
  "classify_tool_call",
191
+ "assert_governance_coverage",
101
192
  ]
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "9.7.0"
52
+ WORKSPACE_OS_VERSION = "9.9.0"
53
53
 
54
54
  # Workspace types separate single-user Personal workspaces from shared
55
55
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.7.0"
20
+ ARCHITECTURE_VERSION_TARGET = "9.9.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -22,6 +22,14 @@ automation suggestions:
22
22
 
23
23
  Every suggestion id is deterministic, so install calls are idempotent and the
24
24
  UI can safely re-request suggestions without duplicates.
25
+
26
+ v9.8.0 quality layer (additive): every suggestion carries a deterministic
27
+ ``confidence`` score plus its ``confidence_factors`` evidence (repeat count,
28
+ distinct phrasings, intent match, related Brain nodes / indexed files);
29
+ suggestions below a minimum confidence are suppressed, duplicate suggestions
30
+ targeting an already-suggested or already-installed starter recipe are
31
+ deduplicated, and the response reports the suppression counters under
32
+ ``quality``.
25
33
  """
26
34
 
27
35
  from __future__ import annotations
@@ -40,6 +48,50 @@ _MIN_PATTERN_COUNT = 2
40
48
  _MAX_HISTORY = 4000
41
49
  _SIGNATURE_SIMILARITY = 0.6
42
50
 
51
+ # Suggestion quality gates (v9.8.0, additive). Suggestions below the minimum
52
+ # confidence are suppressed (insufficient evidence); suggestions between the
53
+ # minimum and the low-confidence threshold are shown but flagged so the UI
54
+ # can render them less prominently.
55
+ _MIN_SUGGESTION_CONFIDENCE = 0.35
56
+ _LOW_CONFIDENCE_THRESHOLD = 0.5
57
+ _KG_GROUNDING_LIMIT = 5
58
+
59
+
60
+ def _question_confidence(
61
+ count: int,
62
+ examples: List[str],
63
+ recipe_id: Optional[str],
64
+ kg_related: Optional[int],
65
+ ) -> tuple:
66
+ """Deterministic confidence for a recurring-question suggestion.
67
+
68
+ Evidence factors: how often the question repeats, how many distinct
69
+ phrasings exist, whether it maps onto a known starter-recipe intent, and
70
+ (when the graph is available) how many Brain nodes relate to it.
71
+ """
72
+ score = 0.3 + 0.5 * min(1.0, (int(count) - 1) / 4)
73
+ score += min(0.15, 0.05 * len(examples or []))
74
+ if recipe_id:
75
+ score += 0.15
76
+ if kg_related:
77
+ score += min(0.2, 0.05 * int(kg_related))
78
+ factors = {
79
+ "repeat_count": int(count),
80
+ "distinct_examples": len(examples or []),
81
+ "intent_match": bool(recipe_id),
82
+ "kg_related_nodes": kg_related,
83
+ }
84
+ return round(min(1.0, score), 2), factors
85
+
86
+
87
+ def _source_confidence(indexed: int, watch_enabled: bool) -> tuple:
88
+ """Deterministic confidence for a knowledge-source digest suggestion."""
89
+ score = 0.25 + 0.6 * min(1.0, int(indexed) / 25)
90
+ if watch_enabled:
91
+ score += 0.1
92
+ factors = {"indexed_files": int(indexed), "watch_enabled": bool(watch_enabled)}
93
+ return round(min(1.0, score), 2), factors
94
+
43
95
  _QUESTION_HINT_RE = re.compile(
44
96
  r"(\?|어때|뭐야|뭐가|뭘까|알려줘|보여줘|정리해|요약해|정리 좀|요약 좀|해줘"
45
97
  r"|what|how|why|when|where|status|summar|remind|list|show me|tell me)",
@@ -223,38 +275,101 @@ class AutomationIntelligenceService:
223
275
  LOGGER.exception("automation intelligence source read failed")
224
276
  return []
225
277
 
278
+ def _kg_related_count(
279
+ self, text: str, *, workspace_id: Optional[str]
280
+ ) -> Optional[int]:
281
+ """Count Brain nodes related to a recurring question (grounding evidence).
282
+
283
+ Returns ``None`` when the graph is unavailable so confidence scoring can
284
+ distinguish "grounding impossible" from "grounded with zero hits".
285
+ Scoping mirrors the conversation-history read: an explicit workspace is
286
+ strict, no workspace falls back to the unscoped/legacy view.
287
+ """
288
+ if not self._enable_graph or not hasattr(self._kg, "search"):
289
+ return None
290
+ try:
291
+ report = self._kg.search(
292
+ str(text or "")[:200],
293
+ limit=_KG_GROUNDING_LIMIT,
294
+ allowed_workspaces={workspace_id} if workspace_id is not None else None,
295
+ include_legacy_global=workspace_id is None,
296
+ )
297
+ except Exception:
298
+ LOGGER.exception("automation intelligence KG grounding failed")
299
+ return None
300
+ if not isinstance(report, dict):
301
+ return None
302
+ return len(report.get("matches") or [])
303
+
226
304
  # ── suggestions ──────────────────────────────────────────────────────
227
305
 
228
- def _installed_suggestion_ids(self, *, workspace_id: Optional[str]) -> Dict[str, Dict[str, Any]]:
229
- installed: Dict[str, Dict[str, Any]] = {}
306
+ def _installed_workflows(
307
+ self, *, workspace_id: Optional[str]
308
+ ) -> tuple:
309
+ """Map installed automation workflows by suggestion id and recipe id.
310
+
311
+ The recipe map lets a recurring-question suggestion that targets an
312
+ already-installed starter recipe surface as *installed* instead of
313
+ re-suggesting the same automation — the install API is idempotent on
314
+ exactly this provenance, so the read side must agree.
315
+ """
316
+ by_suggestion: Dict[str, Dict[str, Any]] = {}
317
+ by_recipe: Dict[str, Dict[str, Any]] = {}
230
318
  if self._store is None:
231
- return installed
319
+ return by_suggestion, by_recipe
232
320
  try:
233
321
  workflows = self._store.list_workflows(workspace_id=workspace_id).get("workflows") or []
234
322
  except Exception:
235
323
  LOGGER.exception("automation intelligence workflow read failed")
236
- return installed
324
+ return by_suggestion, by_recipe
237
325
  for workflow in workflows:
238
326
  metadata = (workflow or {}).get("metadata") or {}
239
- suggestion_id = metadata.get("suggestion_id")
240
- if metadata.get("created_from") == "automation_suggestion" and suggestion_id:
241
- installed[str(suggestion_id)] = workflow
242
- return installed
327
+ created_from = metadata.get("created_from")
328
+ if created_from == "automation_suggestion" and metadata.get("suggestion_id"):
329
+ by_suggestion[str(metadata["suggestion_id"])] = workflow
330
+ elif created_from == "brain_automation_recipe" and metadata.get("recipe_id"):
331
+ by_recipe[str(metadata["recipe_id"])] = workflow
332
+ return by_suggestion, by_recipe
243
333
 
244
334
  def suggestions(
245
335
  self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
246
336
  ) -> Dict[str, Any]:
247
337
  pattern_report = self.question_patterns(user_email=user_email, workspace_id=workspace_id)
248
- installed = self._installed_suggestion_ids(workspace_id=workspace_id)
338
+ by_suggestion, by_recipe = self._installed_workflows(workspace_id=workspace_id)
249
339
 
250
340
  items: List[Dict[str, Any]] = []
341
+ suppressed_low_confidence = 0
342
+ suppressed_duplicates = 0
343
+ seen_recipe_ids: set = set()
344
+
345
+ # Patterns arrive sorted by (count, last_asked) desc, so the first
346
+ # suggestion per recipe is the strongest — later ones targeting the
347
+ # same recipe would install the identical workflow and are suppressed.
251
348
  for pattern in pattern_report["patterns"]:
349
+ recipe_id = pattern["recipe_id"]
350
+ if recipe_id and recipe_id in seen_recipe_ids:
351
+ suppressed_duplicates += 1
352
+ continue
353
+ kg_related = self._kg_related_count(
354
+ pattern["representative"], workspace_id=workspace_id
355
+ )
356
+ confidence, factors = _question_confidence(
357
+ pattern["count"], pattern["examples"], recipe_id, kg_related
358
+ )
359
+ if confidence < _MIN_SUGGESTION_CONFIDENCE:
360
+ suppressed_low_confidence += 1
361
+ continue
362
+ if recipe_id:
363
+ seen_recipe_ids.add(recipe_id)
252
364
  suggestion_id = _stable_id("sug-q", pattern["id"])
365
+ workflow = by_suggestion.get(suggestion_id) or (
366
+ by_recipe.get(str(recipe_id)) if recipe_id else None
367
+ )
253
368
  items.append({
254
369
  "id": suggestion_id,
255
370
  "kind": "recurring_question",
256
371
  "intent": pattern["intent"],
257
- "recipe_id": pattern["recipe_id"],
372
+ "recipe_id": recipe_id,
258
373
  "title": pattern["representative"],
259
374
  "reason": {
260
375
  "type": "repeated_question",
@@ -263,8 +378,11 @@ class AutomationIntelligenceService:
263
378
  "examples": pattern["examples"],
264
379
  },
265
380
  "cadence": "daily",
266
- "installed": suggestion_id in installed,
267
- "workflow_id": (installed.get(suggestion_id) or {}).get("id"),
381
+ "confidence": confidence,
382
+ "confidence_factors": factors,
383
+ "low_confidence": confidence < _LOW_CONFIDENCE_THRESHOLD,
384
+ "installed": workflow is not None,
385
+ "workflow_id": (workflow or {}).get("id"),
268
386
  })
269
387
 
270
388
  for source in self._knowledge_sources():
@@ -272,6 +390,14 @@ class AutomationIntelligenceService:
272
390
  indexed = sum(int(v or 0) for v in file_status.values())
273
391
  if indexed <= 0:
274
392
  continue
393
+ confidence, factors = _source_confidence(
394
+ indexed, bool(source.get("watch_enabled"))
395
+ )
396
+ if confidence < _MIN_SUGGESTION_CONFIDENCE:
397
+ # A barely-indexed folder is not enough evidence for a digest
398
+ # automation yet — do not suggest.
399
+ suppressed_low_confidence += 1
400
+ continue
275
401
  suggestion_id = _stable_id("sug-src", str(source.get("id") or source.get("root_path") or ""))
276
402
  items.append({
277
403
  "id": suggestion_id,
@@ -286,13 +412,22 @@ class AutomationIntelligenceService:
286
412
  "watch_enabled": bool(source.get("watch_enabled")),
287
413
  },
288
414
  "cadence": "when new knowledge arrives",
289
- "installed": suggestion_id in installed,
290
- "workflow_id": (installed.get(suggestion_id) or {}).get("id"),
415
+ "confidence": confidence,
416
+ "confidence_factors": factors,
417
+ "low_confidence": confidence < _LOW_CONFIDENCE_THRESHOLD,
418
+ "installed": suggestion_id in by_suggestion,
419
+ "workflow_id": (by_suggestion.get(suggestion_id) or {}).get("id"),
291
420
  })
292
421
 
293
422
  return {
294
423
  "suggestions": items,
295
424
  "questions_scanned": pattern_report["questions_scanned"],
425
+ "quality": {
426
+ "min_confidence": _MIN_SUGGESTION_CONFIDENCE,
427
+ "low_confidence_threshold": _LOW_CONFIDENCE_THRESHOLD,
428
+ "suppressed_low_confidence": suppressed_low_confidence,
429
+ "suppressed_duplicates": suppressed_duplicates,
430
+ },
296
431
  "consent": {
297
432
  "default_state": "draft_disabled",
298
433
  "local_only": True,
@@ -403,6 +538,7 @@ class AutomationIntelligenceService:
403
538
  "suggestion_kind": kind,
404
539
  "suggestion_title": title,
405
540
  "suggestion_reason": suggestion.get("reason"),
541
+ "suggestion_confidence": suggestion.get("confidence"),
406
542
  "automation_state": "enabled" if enabled else "draft_disabled",
407
543
  "local_only": True,
408
544
  "external_actions": False,
@@ -442,6 +578,7 @@ class AutomationIntelligenceService:
442
578
  "suggestions": suggestion_report["suggestions"],
443
579
  "questions_scanned": suggestion_report["questions_scanned"],
444
580
  "installed": installed,
581
+ "quality": suggestion_report["quality"],
445
582
  "consent": suggestion_report["consent"],
446
583
  "generated_at": _now(),
447
584
  }
@@ -257,6 +257,78 @@ class BrainIntelligenceService:
257
257
  "generated_at": _now(),
258
258
  }
259
259
 
260
+ # ── vector freshness (v9.8.0) ────────────────────────────────────────
261
+
262
+ def vector_freshness(
263
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
264
+ ) -> Dict[str, Any]:
265
+ """Fixed-contract vector index freshness for ``/api/brain/vector-freshness``.
266
+
267
+ Always returns ``{"status": "ready"|"pending"|"unavailable",
268
+ "pending_items": int, "total_items": int, "detail": str}`` and never
269
+ raises. The vector index is store-global (not workspace-partitioned);
270
+ scope arguments are accepted for router symmetry but do not narrow
271
+ the report.
272
+ """
273
+
274
+ def _unavailable(detail: str) -> Dict[str, Any]:
275
+ return {
276
+ "status": "unavailable",
277
+ "pending_items": 0,
278
+ "total_items": 0,
279
+ "detail": detail,
280
+ }
281
+
282
+ if not self._enable_graph or self._kg is None:
283
+ return _unavailable("knowledge graph is disabled; no vector index is configured")
284
+
285
+ freshness_fn = getattr(self._kg, "vector_freshness", None)
286
+ if callable(freshness_fn):
287
+ try:
288
+ raw = freshness_fn() or {}
289
+ except Exception as exc:
290
+ LOGGER.exception("vector freshness read failed")
291
+ return _unavailable(f"vector freshness read failed: {exc}")
292
+ status = str(raw.get("status") or "unavailable")
293
+ if status == "needs_reindex":
294
+ status = "pending"
295
+ if status not in {"ready", "pending", "unavailable"}:
296
+ status = "unavailable"
297
+ return {
298
+ "status": status,
299
+ "pending_items": int(raw.get("pending_items") or 0),
300
+ "total_items": int(raw.get("total_items") or 0),
301
+ "detail": str(raw.get("detail") or ""),
302
+ }
303
+
304
+ # Older/lighter stores: summarize index_status directly.
305
+ status_fn = getattr(self._kg, "index_status", None)
306
+ if callable(status_fn):
307
+ try:
308
+ raw = status_fn() or {}
309
+ except Exception as exc:
310
+ LOGGER.exception("vector index status read failed")
311
+ return _unavailable(f"vector index status unavailable: {exc}")
312
+ pending = int(raw.get("pending_items") or 0)
313
+ total = int(raw.get("source_items") or 0)
314
+ if pending > 0:
315
+ return {
316
+ "status": "pending",
317
+ "pending_items": pending,
318
+ "total_items": total,
319
+ "detail": (
320
+ f"{pending} of {total} items are missing or stale in the vector index"
321
+ ),
322
+ }
323
+ return {
324
+ "status": "ready",
325
+ "pending_items": 0,
326
+ "total_items": total,
327
+ "detail": "vector index is up to date",
328
+ }
329
+
330
+ return _unavailable("this knowledge store does not expose a vector index")
331
+
260
332
  # ── insights digest ──────────────────────────────────────────────────
261
333
 
262
334
  def insights(