ltcai 9.9.5 → 10.0.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 (129) hide show
  1. package/README.md +39 -37
  2. package/docs/CHANGELOG.md +234 -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 +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +38 -8
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/retrieval_vector.py +10 -0
  19. package/lattice_brain/ingestion.py +9 -106
  20. package/lattice_brain/ingestion_jobs.py +133 -0
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +8 -0
  24. package/latticeai/api/chat.py +9 -0
  25. package/latticeai/api/chat_agent_http.py +119 -2
  26. package/latticeai/api/chat_contracts.py +9 -0
  27. package/latticeai/api/chat_documents.py +16 -0
  28. package/latticeai/api/evidence_actions.py +48 -0
  29. package/latticeai/api/permission_mode.py +61 -0
  30. package/latticeai/api/project_sessions.py +112 -0
  31. package/latticeai/api/voice_capture.py +89 -0
  32. package/latticeai/app_factory.py +53 -0
  33. package/latticeai/core/agent.py +381 -43
  34. package/latticeai/core/agent_permission.py +160 -0
  35. package/latticeai/core/agent_profiles.py +110 -0
  36. package/latticeai/core/artifact_ledger.py +109 -0
  37. package/latticeai/core/context_builder.py +101 -2
  38. package/latticeai/core/legacy_compatibility.py +1 -1
  39. package/latticeai/core/marketplace.py +1 -1
  40. package/latticeai/core/permission_mode.py +296 -0
  41. package/latticeai/core/project_sessions.py +337 -0
  42. package/latticeai/core/run_explain.py +421 -0
  43. package/latticeai/core/run_store.py +5 -0
  44. package/latticeai/core/workspace_os.py +15 -80
  45. package/latticeai/core/workspace_review_items.py +107 -0
  46. package/latticeai/integrations/telegram_bot.py +188 -0
  47. package/latticeai/runtime/chat_wiring.py +8 -0
  48. package/latticeai/runtime/context_runtime.py +11 -0
  49. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  50. package/latticeai/runtime/router_registration.py +9 -0
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/brain_intelligence.py +105 -0
  54. package/latticeai/services/evidence_actions.py +254 -0
  55. package/latticeai/services/funnel_metrics.py +111 -16
  56. package/latticeai/services/local_knowledge.py +26 -0
  57. package/latticeai/services/memory_service.py +12 -0
  58. package/latticeai/services/permission_mode_service.py +170 -0
  59. package/latticeai/services/product_readiness.py +1 -1
  60. package/latticeai/services/tool_dispatch.py +73 -4
  61. package/latticeai/services/voice_capture.py +189 -0
  62. package/package.json +6 -4
  63. package/scripts/check_bundle_budget.mjs +11 -0
  64. package/scripts/check_current_release_docs.mjs +1 -1
  65. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  66. package/skills/meeting_notes/SKILL.md +82 -0
  67. package/skills/meeting_notes/examples.md +41 -0
  68. package/skills/meeting_notes/risk.json +9 -0
  69. package/skills/meeting_notes/schema.json +55 -0
  70. package/skills/weekly_review/SKILL.md +75 -0
  71. package/skills/weekly_review/examples.md +39 -0
  72. package/skills/weekly_review/risk.json +9 -0
  73. package/skills/weekly_review/schema.json +55 -0
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -29
  78. package/static/app/assets/Act-BtCREeN1.js +1 -0
  79. package/static/app/assets/AdminConsole-TPeeN18T.js +1 -0
  80. package/static/app/assets/Brain-BKs6JAp0.js +321 -0
  81. package/static/app/assets/BrainHome-BPGOvSd6.js +2 -0
  82. package/static/app/assets/BrainSignals-CtzQZ15J.js +1 -0
  83. package/static/app/assets/Capture-1_NaHWqB.js +1 -0
  84. package/static/app/assets/CommandPalette-pqvQOXe4.js +1 -0
  85. package/static/app/assets/Library-DhvoPvC7.js +1 -0
  86. package/static/app/assets/{LivingBrain-BVWk58MW.js → LivingBrain-DlQ20Q75.js} +1 -1
  87. package/static/app/assets/ProductFlow-BZvGDRi_.js +1 -0
  88. package/static/app/assets/ReviewCard-BWgI0D2s.js +3 -0
  89. package/static/app/assets/System-CSMdYLMy.js +1 -0
  90. package/static/app/assets/activity-Dlfk8YC7.js +1 -0
  91. package/static/app/assets/{bot-VmP0kkeA.js → bot-CDvUB76P.js} +1 -1
  92. package/static/app/assets/brain-xczrohrt.js +1 -0
  93. package/static/app/assets/button-SOdH3Oyf.js +1 -0
  94. package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause-CG1ythH4.js} +1 -1
  95. package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-HXwvjS6W.js} +1 -1
  96. package/static/app/assets/{cpu-CJhjRtNq.js → cpu-B0d-rGyk.js} +1 -1
  97. package/static/app/assets/download-BGIkTQL6.js +1 -0
  98. package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-Dst_Z0_K.js} +1 -1
  99. package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-D53MsWkV.js} +1 -1
  100. package/static/app/assets/index-C_IrlQMV.js +10 -0
  101. package/static/app/assets/index-FxDusbr0.css +2 -0
  102. package/static/app/assets/{input-CSi6OBJ9.js → input-C5m0riF6.js} +1 -1
  103. package/static/app/assets/{network-Ck1nqc-v.js → network-C5a-E5iS.js} +1 -1
  104. package/static/app/assets/primitives-vNXYf58F.js +1 -0
  105. package/static/app/assets/search-DhbSgW6m.js +1 -0
  106. package/static/app/assets/shield-alert-Cc-WVXqN.js +1 -0
  107. package/static/app/assets/{textarea-C4wM0D-Z.js → textarea-BkZ0EqVO.js} +1 -1
  108. package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-CTtKbAOU.js} +1 -1
  109. package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-Dx1fi4Wu.js} +1 -1
  110. package/static/app/assets/users-BFpQXtEF.js +1 -0
  111. package/static/app/assets/utils-BA_lmW3J.js +7 -0
  112. package/static/app/assets/workspace-DBPB0jkX.js +1 -0
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/Act-DmxfbqBp.js +0 -1
  116. package/static/app/assets/Brain-DK_zKCda.js +0 -321
  117. package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
  118. package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
  119. package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
  120. package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
  121. package/static/app/assets/Library-4As9VUvy.js +0 -1
  122. package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
  123. package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
  124. package/static/app/assets/System-DSR2zKWd.js +0 -1
  125. package/static/app/assets/index-7FAfYm4v.css +0 -2
  126. package/static/app/assets/index-CEu0Wqjl.js +0 -10
  127. package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
  128. package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
  129. package/static/app/assets/utils-BqoznzGP.js +0 -7
@@ -42,10 +42,9 @@ from __future__ import annotations
42
42
  import fnmatch
43
43
  import hashlib
44
44
  import os
45
- import threading
46
45
  from dataclasses import dataclass, field
47
46
  from pathlib import Path
48
- from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
47
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
49
48
 
50
49
  from .runtime.hooks import dispatch_tool
51
50
  from .utils import utc_now_iso
@@ -353,110 +352,14 @@ def _matches_ignore(
353
352
  return False
354
353
 
355
354
 
356
- # --- Large candidate 1 slice: incremental / background ingestion support ---
357
- JOB_ERRORS_CAP = 50 # per-job error records kept (failed count keeps counting)
358
-
359
-
360
- @dataclass
361
- class BackgroundIngestionJob:
362
- """Job descriptor + progress state for background/incremental indexing.
363
-
364
- ``done_indices`` tracks per-item completion so an interrupted or partially
365
- failed job can be *resumed* from the remaining items instead of restarting.
366
- ``errors`` is capped at ``max_errors`` records; ``failed`` keeps counting.
367
- """
368
- job_id: str
369
- items: List[IngestionItem]
370
- status: str = "queued" # queued | running | completed | failed | partial
371
- created_at: str = field(default_factory=utc_now_iso)
372
- updated_at: str = field(default_factory=utc_now_iso)
373
- processed: int = 0
374
- failed: int = 0
375
- total: int = 0
376
- errors: List[Dict[str, Any]] = field(default_factory=list)
377
- incremental: bool = True
378
- user_email: Optional[str] = None
379
- max_errors: int = JOB_ERRORS_CAP
380
- done_indices: Set[int] = field(default_factory=set)
381
-
382
- def touch(self) -> None:
383
- self.updated_at = utc_now_iso()
384
-
385
- def record_error(self, index: int, item: IngestionItem, detail: Any) -> None:
386
- self.failed += 1
387
- if len(self.errors) < self.max_errors:
388
- self.errors.append({
389
- "index": index,
390
- "source": item.source_uri or item.path or item.title or item.source_type,
391
- "detail": str(detail)[:500],
392
- })
393
-
394
- def remaining_indices(self) -> List[int]:
395
- return [i for i in range(len(self.items)) if i not in self.done_indices]
396
-
397
- def as_dict(self) -> Dict[str, Any]:
398
- """Frozen job schema consumed by ``/api/ingestion/jobs*``."""
399
- return {
400
- "job_id": self.job_id,
401
- "status": self.status,
402
- "total": self.total,
403
- "processed": self.processed,
404
- "failed": self.failed,
405
- "errors": list(self.errors),
406
- "created_at": self.created_at,
407
- "updated_at": self.updated_at,
408
- }
409
-
410
-
411
- class BackgroundIngestionQueue:
412
- """Simple in-memory queue for background incremental ingestion.
413
-
414
- For large corpus: this is the seam where a real scheduler / worker pool
415
- (celery, rq, or internal thread) can be plugged later without changing callers.
416
- Supports incremental (skip duplicates) vs force reindex.
417
- """
418
- def __init__(self) -> None:
419
- self._jobs: Dict[str, BackgroundIngestionJob] = {}
420
- self._counter = 0
421
- self._lock = threading.Lock()
422
-
423
- def schedule(
424
- self,
425
- items: List[IngestionItem],
426
- *,
427
- incremental: bool = True,
428
- user_email: Optional[str] = None,
429
- ) -> BackgroundIngestionJob:
430
- with self._lock:
431
- self._counter += 1
432
- job_id = f"bg_ingest_{self._counter:04d}"
433
- job = BackgroundIngestionJob(
434
- job_id=job_id,
435
- items=items,
436
- total=len(items),
437
- incremental=incremental,
438
- user_email=user_email,
439
- )
440
- # annotate items for downstream
441
- for it in job.items:
442
- # attach flag without breaking dataclass defaults (use metadata)
443
- it.metadata = {**it.metadata, "incremental": incremental, "bg_job": job_id}
444
- with self._lock:
445
- self._jobs[job_id] = job
446
- return job
447
-
448
- def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
449
- return self._jobs.get(job_id)
450
-
451
- def list_pending(self) -> List[BackgroundIngestionJob]:
452
- return [j for j in self._jobs.values() if j.status == "queued"]
453
-
454
- def list_recent(self, limit: int = 20) -> List[BackgroundIngestionJob]:
455
- """Most recent jobs first (insertion order is schedule order)."""
456
- limit = max(1, int(limit))
457
- with self._lock:
458
- jobs = list(self._jobs.values())
459
- return list(reversed(jobs))[:limit]
355
+ # Background job scheduling + progress lives in its own module (v9.9.6):
356
+ # the pipeline owns "ingest one item", the queue owns "schedule many and
357
+ # report progress". Re-exported so every existing import keeps working.
358
+ from .ingestion_jobs import ( # noqa: E402,F401 — re-export for existing importers
359
+ JOB_ERRORS_CAP,
360
+ BackgroundIngestionJob,
361
+ BackgroundIngestionQueue,
362
+ )
460
363
 
461
364
 
462
365
  @dataclass
@@ -0,0 +1,133 @@
1
+ """Background ingestion jobs — queue + per-job progress (v9.9.6 extraction).
2
+
3
+ Split out of :mod:`lattice_brain.ingestion` (behaviour-preserving move, review
4
+ 2026-07-27 P2 #8 "ingestion job/watch 분리"). The pipeline owns *what it means
5
+ to ingest one item*; this module owns *scheduling many of them and reporting
6
+ progress* — a genuinely separate concern with its own frozen wire schema
7
+ (``/api/ingestion/jobs*``) and its own resume semantics.
8
+
9
+ The seam is also where a real scheduler (thread pool, rq, celery) would plug
10
+ in without touching the pipeline.
11
+
12
+ ``IngestionItem`` is imported only for type checking: the pipeline module owns
13
+ that dataclass, and a runtime import here would be circular.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ from dataclasses import dataclass, field
20
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set
21
+
22
+ from .utils import utc_now_iso
23
+
24
+ if TYPE_CHECKING: # pragma: no cover - typing only
25
+ from .ingestion import IngestionItem
26
+
27
+
28
+ JOB_ERRORS_CAP = 50 # per-job error records kept (failed count keeps counting)
29
+
30
+
31
+ @dataclass
32
+ class BackgroundIngestionJob:
33
+ """Job descriptor + progress state for background/incremental indexing.
34
+
35
+ ``done_indices`` tracks per-item completion so an interrupted or partially
36
+ failed job can be *resumed* from the remaining items instead of restarting.
37
+ ``errors`` is capped at ``max_errors`` records; ``failed`` keeps counting.
38
+ """
39
+ job_id: str
40
+ items: List[IngestionItem]
41
+ status: str = "queued" # queued | running | completed | failed | partial
42
+ created_at: str = field(default_factory=utc_now_iso)
43
+ updated_at: str = field(default_factory=utc_now_iso)
44
+ processed: int = 0
45
+ failed: int = 0
46
+ total: int = 0
47
+ errors: List[Dict[str, Any]] = field(default_factory=list)
48
+ incremental: bool = True
49
+ user_email: Optional[str] = None
50
+ max_errors: int = JOB_ERRORS_CAP
51
+ done_indices: Set[int] = field(default_factory=set)
52
+
53
+ def touch(self) -> None:
54
+ self.updated_at = utc_now_iso()
55
+
56
+ def record_error(self, index: int, item: IngestionItem, detail: Any) -> None:
57
+ self.failed += 1
58
+ if len(self.errors) < self.max_errors:
59
+ self.errors.append({
60
+ "index": index,
61
+ "source": item.source_uri or item.path or item.title or item.source_type,
62
+ "detail": str(detail)[:500],
63
+ })
64
+
65
+ def remaining_indices(self) -> List[int]:
66
+ return [i for i in range(len(self.items)) if i not in self.done_indices]
67
+
68
+ def as_dict(self) -> Dict[str, Any]:
69
+ """Frozen job schema consumed by ``/api/ingestion/jobs*``."""
70
+ return {
71
+ "job_id": self.job_id,
72
+ "status": self.status,
73
+ "total": self.total,
74
+ "processed": self.processed,
75
+ "failed": self.failed,
76
+ "errors": list(self.errors),
77
+ "created_at": self.created_at,
78
+ "updated_at": self.updated_at,
79
+ }
80
+
81
+
82
+ class BackgroundIngestionQueue:
83
+ """Simple in-memory queue for background incremental ingestion.
84
+
85
+ For large corpus: this is the seam where a real scheduler / worker pool
86
+ (celery, rq, or internal thread) can be plugged later without changing callers.
87
+ Supports incremental (skip duplicates) vs force reindex.
88
+ """
89
+ def __init__(self) -> None:
90
+ self._jobs: Dict[str, BackgroundIngestionJob] = {}
91
+ self._counter = 0
92
+ self._lock = threading.Lock()
93
+
94
+ def schedule(
95
+ self,
96
+ items: List[IngestionItem],
97
+ *,
98
+ incremental: bool = True,
99
+ user_email: Optional[str] = None,
100
+ ) -> BackgroundIngestionJob:
101
+ with self._lock:
102
+ self._counter += 1
103
+ job_id = f"bg_ingest_{self._counter:04d}"
104
+ job = BackgroundIngestionJob(
105
+ job_id=job_id,
106
+ items=items,
107
+ total=len(items),
108
+ incremental=incremental,
109
+ user_email=user_email,
110
+ )
111
+ # annotate items for downstream
112
+ for it in job.items:
113
+ # attach flag without breaking dataclass defaults (use metadata)
114
+ it.metadata = {**it.metadata, "incremental": incremental, "bg_job": job_id}
115
+ with self._lock:
116
+ self._jobs[job_id] = job
117
+ return job
118
+
119
+ def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
120
+ return self._jobs.get(job_id)
121
+
122
+ def list_pending(self) -> List[BackgroundIngestionJob]:
123
+ return [j for j in self._jobs.values() if j.status == "queued"]
124
+
125
+ def list_recent(self, limit: int = 20) -> List[BackgroundIngestionJob]:
126
+ """Most recent jobs first (insertion order is schedule order)."""
127
+ limit = max(1, int(limit))
128
+ with self._lock:
129
+ jobs = list(self._jobs.values())
130
+ return list(reversed(jobs))[:limit]
131
+
132
+
133
+ __all__ = ["JOB_ERRORS_CAP", "BackgroundIngestionJob", "BackgroundIngestionQueue"]
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
48
48
  from ..utils import now_iso as _now
49
49
 
50
50
 
51
- MULTI_AGENT_VERSION = "9.9.5"
51
+ MULTI_AGENT_VERSION = "10.0.0"
52
52
 
53
53
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
54
54
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.9.5"
3
+ __version__ = "10.0.0"
@@ -57,6 +57,14 @@ def create_brain_intelligence_router(
57
57
  scope = gate_read(request)
58
58
  return service.contradictions(user_email=user, workspace_id=scope)
59
59
 
60
+ @router.get("/api/brain/garden")
61
+ async def brain_garden(request: Request, limit: int = 8):
62
+ """Knowledge garden overview (v9.9.7): recent / contradictions /
63
+ stale / frequent, read-only and workspace-scoped."""
64
+ user = require_user(request)
65
+ scope = gate_read(request)
66
+ return service.garden_overview(user_email=user, workspace_id=scope, limit=limit)
67
+
60
68
  @router.get("/api/brain/vector-freshness")
61
69
  async def brain_vector_freshness(request: Request):
62
70
  """Vector index freshness summary (read-only, never raises).
@@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
16
16
  from fastapi.responses import JSONResponse, StreamingResponse
17
17
 
18
18
  from latticeai.api.chat_agent_http import AgentHTTPController
19
+ from latticeai.core.project_sessions import ProjectSessionStore
19
20
  from latticeai.api.chat_contracts import (
20
21
  AgentEvalRequest,
21
22
  AgentRequest,
@@ -197,6 +198,14 @@ def create_chat_router(context: AppContext) -> APIRouter:
197
198
  if context.data_dir
198
199
  else None
199
200
  ),
201
+ # Multi-turn project loop (v9.9.6): optional — without a data dir the
202
+ # agent runs exactly as before, one run at a time with no project state.
203
+ project_sessions=(
204
+ ProjectSessionStore(Path(context.data_dir) / "project_sessions")
205
+ if context.data_dir
206
+ else None
207
+ ),
208
+ artifact_ledger=context.artifact_ledger,
200
209
  )
201
210
  intent_controller = ChatIntentController(
202
211
  model_router=model_router,
@@ -14,11 +14,14 @@ from pathlib import Path
14
14
  from typing import Any, Dict, Optional
15
15
 
16
16
  from fastapi import APIRouter, HTTPException, Request
17
+ from fastapi.responses import StreamingResponse
17
18
 
18
19
  from lattice_brain.runtime.hooks import dispatch_tool
19
20
  from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
20
21
  from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
22
+ from latticeai.api.chat_stream import agent_live_stream
21
23
  from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
24
+ from latticeai.core.run_explain import explain_run
22
25
  from latticeai.core.run_store import (
23
26
  AgentRunStore,
24
27
  hash_approval_token,
@@ -48,6 +51,8 @@ class AgentHTTPController:
48
51
  ensure_agent_root: Any,
49
52
  funnel_metrics: Any = None,
50
53
  run_store: Any = None,
54
+ project_sessions: Any = None,
55
+ artifact_ledger: Any = None,
51
56
  ) -> None:
52
57
  self.runtime = runtime
53
58
  self.model_router = model_router
@@ -65,6 +70,12 @@ class AgentHTTPController:
65
70
  self.agent_root = Path(agent_root)
66
71
  self.ensure_agent_root = ensure_agent_root
67
72
  self.funnel_metrics = funnel_metrics
73
+ # Multi-turn project loop (v9.9.6). Optional: without a store the
74
+ # agent behaves exactly as before — single runs, no project memory.
75
+ self.project_sessions = project_sessions
76
+ # Re-search loop (v9.9.6): the next turn must see files this run just
77
+ # wrote, even before asynchronous indexing catches up.
78
+ self.artifact_ledger = artifact_ledger
68
79
  self._pending_lock = threading.Lock()
69
80
  # awaiting_approval runs: run_id -> paused run state + approval token.
70
81
  # Fail-closed: governed steps only ever execute after resume presents
@@ -91,6 +102,24 @@ class AgentHTTPController:
91
102
 
92
103
  @router.post("/agent")
93
104
  async def agent(req: AgentRequest, request: Request):
105
+ if req.stream:
106
+ # Surface parity (v9.9.7): the web app already watches the loop
107
+ # live through the chat route. `/agent` clients (VS Code) get
108
+ # the same named `agent_step` frames here, followed by the
109
+ # exact terminal payload the JSON response returns — a client
110
+ # that ignores named events sees the historical shape.
111
+ async def start(observer):
112
+ return await self.agent(req, request, on_step=observer)
113
+
114
+ return StreamingResponse(
115
+ agent_live_stream(
116
+ start,
117
+ router=self.model_router,
118
+ model_id=req.executing_model,
119
+ ),
120
+ media_type="text/event-stream",
121
+ headers={"X-Routed-To": "agent"},
122
+ )
94
123
  return await self.agent(req, request)
95
124
 
96
125
  @router.post("/agent/resume")
@@ -238,6 +267,18 @@ class AgentHTTPController:
238
267
  ctx.on_step = on_step
239
268
  ctx.executing_model = req.executing_model
240
269
  ctx.reviewing_model = req.reviewing_model
270
+ ctx.project_context = self._project_summary(req, current_user)
271
+ # Autonomy dial (v9.9.8): resolve the user/workspace-scoped mode once so
272
+ # the plan gate and every per-tool gate in this run agree, even if the
273
+ # stored preference changes mid-run.
274
+ mode_probe = getattr(self.runtime, "resolve_permission_mode", None)
275
+ if callable(mode_probe):
276
+ try:
277
+ ctx.permission_mode = mode_probe(
278
+ user_email=current_user, workspace_id=req.workspace_id,
279
+ ).value
280
+ except Exception: # noqa: BLE001 — never fail a run over the dial
281
+ ctx.permission_mode = None
241
282
  ctx.state = AgentState.PLANNING
242
283
  ctx.state_history.append(ctx.state.value)
243
284
  await self.runtime.plan(
@@ -288,6 +329,55 @@ class AgentHTTPController:
288
329
  max_retry,
289
330
  )
290
331
 
332
+ def _project_summary(self, req: AgentRequest, current_user: str) -> str:
333
+ """Where this project stands, for the planner/executor prompt.
334
+
335
+ Advisory: a missing store, an unknown project id, or a read failure
336
+ yields "" and the run proceeds as a standalone run.
337
+ """
338
+ project_id = getattr(req, "project_id", None)
339
+ if not project_id or self.project_sessions is None:
340
+ return ""
341
+ try:
342
+ return self.project_sessions.summary(
343
+ str(project_id),
344
+ user_email=current_user or None,
345
+ workspace_id=req.workspace_id,
346
+ )
347
+ except Exception as exc: # noqa: BLE001 — project context never gates a run
348
+ logging.warning("project session summary failed: %s", exc)
349
+ return ""
350
+
351
+ def _record_project_run(
352
+ self,
353
+ req: AgentRequest,
354
+ current_user: str,
355
+ ctx: AgentRunContext,
356
+ payload: Dict[str, Any],
357
+ ) -> None:
358
+ """Fold a finished run into its project session (best effort).
359
+
360
+ The verification result is stored exactly as the run reported it — a
361
+ NEEDS_REVIEW run never becomes a project's "done".
362
+ """
363
+ project_id = getattr(req, "project_id", None)
364
+ if not project_id or self.project_sessions is None:
365
+ return
366
+ try:
367
+ self.project_sessions.record_run(
368
+ str(project_id),
369
+ run_id=str(payload.get("run_id") or ""),
370
+ goal=str(ctx.plan.get("goal") or req.message),
371
+ status=str(payload.get("status") or ""),
372
+ final_state=ctx.state.value,
373
+ files=payload.get("created_files") or [],
374
+ explanation=payload.get("explanation"),
375
+ user_email=current_user or None,
376
+ workspace_id=req.workspace_id,
377
+ )
378
+ except Exception as exc: # noqa: BLE001 — bookkeeping never fails a run
379
+ logging.warning("project session record failed: %s", exc)
380
+
291
381
  def _pause_for_approval(
292
382
  self,
293
383
  ctx: AgentRunContext,
@@ -541,7 +631,8 @@ class AgentHTTPController:
541
631
  self.funnel_metrics.increment("needs_review_runs")
542
632
  except Exception as exc: # noqa: BLE001
543
633
  logging.warning("funnel metrics increment failed: %s", exc)
544
- return {
634
+ loop_summary = ctx.trace.summary()
635
+ payload = {
545
636
  "status": "ok" if ctx.state == AgentState.DONE else "failed",
546
637
  "response": message,
547
638
  "workspace": str(self.agent_root),
@@ -550,8 +641,34 @@ class AgentHTTPController:
550
641
  "final_state": ctx.state.value,
551
642
  "created_files": collect_created_files(ctx.transcript),
552
643
  "artifacts": collect_artifacts(ctx.transcript),
553
- "loop": ctx.trace.summary(),
644
+ "loop": loop_summary,
645
+ # Plain-language outcome (v9.9.6): the loop counters say a weak
646
+ # model needed three repairs; this says it in a sentence a person
647
+ # can read, and never upgrades a non-success into a success.
648
+ "explanation": explain_run(
649
+ state=ctx.state,
650
+ loop=loop_summary,
651
+ transcript=ctx.transcript,
652
+ max_retry=max_retry,
653
+ ),
554
654
  }
655
+ # Multi-turn project loop (v9.9.6): the project remembers this run's
656
+ # files and its honest verdict, so the next turn continues instead of
657
+ # restarting.
658
+ self._record_project_run(req, current_user, ctx, payload)
659
+ if self.artifact_ledger is not None and payload.get("created_files"):
660
+ try:
661
+ self.artifact_ledger.record(
662
+ payload["created_files"],
663
+ user_email=current_user or None,
664
+ conversation_id=req.conversation_id,
665
+ workspace_id=req.workspace_id,
666
+ )
667
+ except Exception as exc: # noqa: BLE001 — advisory context only
668
+ logging.warning("artifact ledger record failed: %s", exc)
669
+ if getattr(req, "project_id", None):
670
+ payload["project_id"] = str(req.project_id)
671
+ return payload
555
672
 
556
673
  async def resume(
557
674
  self,
@@ -36,6 +36,15 @@ class AgentRequest(BaseModel):
36
36
  executing_model: Optional[str] = None
37
37
  reviewing_model: Optional[str] = None
38
38
  human_in_loop: bool = False
39
+ # Multi-turn project loop (v9.9.6): when set, the run reads the project's
40
+ # accumulated state (files, open TODOs, last verification) and folds its
41
+ # own outcome back in when it finishes.
42
+ project_id: Optional[str] = None
43
+ # Live loop visibility (v9.9.7): stream named `agent_step` SSE frames while
44
+ # the run executes, then the same terminal payload as the JSON response.
45
+ # Surface parity — the web app already gets this through the chat route;
46
+ # `/agent` clients (VS Code) need it on their own endpoint.
47
+ stream: bool = False
39
48
 
40
49
 
41
50
  class AgentResumeRequest(BaseModel):
@@ -134,6 +134,11 @@ class DocumentGenerationCoordinator:
134
134
  logging.debug(
135
135
  "Document generation context retrieved from knowledge graph."
136
136
  )
137
+ # Shared context contract (v9.9.6): document generation now
138
+ # reports the same context_quality signal chat does, so a
139
+ # thin-context document is as visible as a thin-context answer.
140
+ if retrieval.get("stats", {}).get("budget_trimmed"):
141
+ logging.debug("Document context trimmed to the shared budget.")
137
142
  except Exception as exc:
138
143
  logging.warning("Knowledge graph reinforcement skipped: %s", exc)
139
144
  return DocumentPreparation(is_document, context, retrieval)
@@ -165,6 +170,16 @@ class DocumentGenerationCoordinator:
165
170
  graph_markdown = (preparation.retrieval or {}).get("context_markdown", "")
166
171
  system_prompt = session.get_system_prompt(graph_markdown)
167
172
  footnote = format_sources_footnote((preparation.retrieval or {}).get("sources", []))
173
+ # Shared context contract (v9.9.6): the document path records the same
174
+ # context_quality + assembly trace on the answer trace that chat does,
175
+ # so both surfaces answer "how well was this grounded?" identically.
176
+ context_quality = (preparation.retrieval or {}).get("context_quality")
177
+ if isinstance(trace_seed, dict):
178
+ if context_quality:
179
+ trace_seed["context_quality"] = context_quality
180
+ assembly_trace = (preparation.retrieval or {}).get("trace")
181
+ if assembly_trace:
182
+ trace_seed["context_assembly"] = assembly_trace
168
183
 
169
184
  if req.stream:
170
185
  async def stream_document():
@@ -245,6 +260,7 @@ class DocumentGenerationCoordinator:
245
260
  "response": response_text,
246
261
  "trace_id": trace_record["id"],
247
262
  "trace": trace_record,
263
+ **({"context_quality": context_quality} if context_quality else {}),
248
264
  }
249
265
  )
250
266
 
@@ -0,0 +1,48 @@
1
+ """Evidence → action router (v9.9.6).
2
+
3
+ ``POST /api/evidence/actions`` turns an answer's citations into ready-to-send,
4
+ evidence-scoped follow-up prompts. Read-only and deterministic: it resolves
5
+ graph nodes and composes text — no model call, no writes. Execution stays on
6
+ the existing chat / file-generation path so there is exactly one road from a
7
+ prompt to an artifact.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, List, Optional
13
+
14
+ from fastapi import APIRouter, Request
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ class EvidenceActionsRequest(BaseModel):
19
+ """Citations from one answer (the ids the grounding badge reported)."""
20
+
21
+ question: str = ""
22
+ source_ids: List[str] = Field(default_factory=list)
23
+ language: str = "ko"
24
+
25
+
26
+ def create_evidence_actions_router(
27
+ *,
28
+ service: Any,
29
+ require_user: Callable[[Request], Any],
30
+ allowed_workspaces_for: Optional[Callable[[Any], Any]] = None,
31
+ ) -> APIRouter:
32
+ router = APIRouter()
33
+
34
+ @router.post("/api/evidence/actions")
35
+ async def evidence_actions(req: EvidenceActionsRequest, request: Request):
36
+ user = require_user(request)
37
+ scope = allowed_workspaces_for(user) if allowed_workspaces_for is not None else None
38
+ return service.actions_for(
39
+ question=req.question,
40
+ source_ids=req.source_ids,
41
+ language=req.language,
42
+ allowed_workspaces=scope,
43
+ )
44
+
45
+ return router
46
+
47
+
48
+ __all__ = ["create_evidence_actions_router", "EvidenceActionsRequest"]
@@ -0,0 +1,61 @@
1
+ """Permission mode API — strict / trusted / bypass dial (v9.9.8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from pydantic import BaseModel, Field
9
+
10
+ from latticeai.core.permission_mode import mode_catalog, normalize_mode
11
+ from latticeai.services.permission_mode_service import PermissionModeService
12
+
13
+
14
+ class SetPermissionModeRequest(BaseModel):
15
+ mode: str = Field(..., description="strict | trusted | bypass")
16
+ workspace_id: Optional[str] = None
17
+ acknowledge_risk: bool = False
18
+
19
+
20
+ def create_permission_mode_router(
21
+ *,
22
+ service: PermissionModeService,
23
+ require_user: Callable[..., str],
24
+ ) -> APIRouter:
25
+ router = APIRouter(tags=["permission-mode"])
26
+
27
+ @router.get("/api/permission-mode")
28
+ async def get_permission_mode(
29
+ request: Request,
30
+ workspace_id: Optional[str] = None,
31
+ ):
32
+ user = require_user(request)
33
+ header_ws = request.headers.get("X-Workspace-Id")
34
+ scope = workspace_id or (header_ws.strip() if header_ws else None)
35
+ return service.get(user_email=user, workspace_id=scope)
36
+
37
+ @router.get("/api/permission-mode/catalog")
38
+ async def permission_mode_catalog(request: Request):
39
+ require_user(request)
40
+ return {"modes": mode_catalog()}
41
+
42
+ @router.post("/api/permission-mode")
43
+ async def set_permission_mode(body: SetPermissionModeRequest, request: Request):
44
+ user = require_user(request)
45
+ header_ws = request.headers.get("X-Workspace-Id")
46
+ scope = body.workspace_id or (header_ws.strip() if header_ws else None)
47
+ try:
48
+ return service.set_mode(
49
+ normalize_mode(body.mode),
50
+ user_email=user,
51
+ workspace_id=scope,
52
+ acknowledge_risk=body.acknowledge_risk,
53
+ source="api",
54
+ )
55
+ except PermissionError as exc:
56
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
57
+
58
+ return router
59
+
60
+
61
+ __all__ = ["create_permission_mode_router", "SetPermissionModeRequest"]