loki-mode 6.60.0 → 6.62.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 (64) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/app-runner.sh +34 -8
  4. package/autonomy/completion-council.sh +70 -32
  5. package/autonomy/issue-parser.sh +4 -7
  6. package/autonomy/loki +238 -119
  7. package/autonomy/notification-checker.py +49 -23
  8. package/autonomy/run.sh +162 -79
  9. package/autonomy/sandbox.sh +91 -24
  10. package/bin/loki-mode.js +1 -2
  11. package/bin/postinstall.js +10 -4
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/control.py +46 -36
  14. package/dashboard/database.py +21 -4
  15. package/dashboard/server.py +107 -78
  16. package/docs/BUG-AUDIT-v6.61.0.md +957 -0
  17. package/docs/INSTALLATION.md +2 -2
  18. package/events/bus.py +129 -28
  19. package/events/bus.ts +41 -27
  20. package/events/emit.sh +1 -1
  21. package/integrations/openclaw/README.md +139 -0
  22. package/integrations/openclaw/SKILL.md +88 -0
  23. package/integrations/openclaw/bridge/__init__.py +1 -0
  24. package/integrations/openclaw/bridge/__main__.py +88 -0
  25. package/integrations/openclaw/bridge/schema_map.py +180 -0
  26. package/integrations/openclaw/bridge/watcher.py +100 -0
  27. package/integrations/openclaw/scripts/format-progress.sh +80 -0
  28. package/integrations/openclaw/scripts/poll-status.sh +74 -0
  29. package/integrations/vibe-kanban.md +289 -0
  30. package/mcp/__init__.py +1 -1
  31. package/mcp/server.py +96 -73
  32. package/memory/consolidation.py +21 -6
  33. package/memory/engine.py +53 -26
  34. package/memory/layers/index_layer.py +16 -3
  35. package/memory/layers/timeline_layer.py +16 -3
  36. package/memory/retrieval.py +4 -1
  37. package/memory/schemas.py +4 -2
  38. package/memory/storage.py +25 -4
  39. package/memory/token_economics.py +9 -2
  40. package/memory/vector_index.py +2 -2
  41. package/package.json +3 -1
  42. package/providers/cline.sh +5 -4
  43. package/providers/codex.sh +27 -5
  44. package/providers/gemini.sh +59 -23
  45. package/providers/loader.sh +3 -2
  46. package/skills/parallel-workflows.md +9 -7
  47. package/state/__init__.py +10 -0
  48. package/state/index.ts +18 -0
  49. package/state/manager.py +1801 -0
  50. package/state/manager.ts +1774 -0
  51. package/state/sqlite_backend.py +188 -0
  52. package/state/test_manager.py +703 -0
  53. package/state/test_manager.ts +366 -0
  54. package/templates/README.md +19 -4
  55. package/templates/dashboard.md +45 -0
  56. package/templates/data-pipeline.md +45 -0
  57. package/templates/game.md +48 -0
  58. package/templates/microservice.md +49 -0
  59. package/templates/npm-library.md +42 -0
  60. package/templates/rest-api.md +170 -33
  61. package/templates/slack-bot.md +48 -0
  62. package/templates/web-scraper.md +45 -0
  63. package/web-app/server.py +360 -191
  64. package/templates/saas-app.md +0 -42
@@ -16,7 +16,7 @@ from dataclasses import asdict
16
16
  from contextlib import asynccontextmanager
17
17
  from datetime import datetime, timedelta, timezone
18
18
  from pathlib import Path as _Path
19
- from typing import Any, Optional
19
+ from typing import Any, Literal, Optional
20
20
  import re
21
21
 
22
22
  from fastapi import (
@@ -30,7 +30,7 @@ from fastapi import (
30
30
  )
31
31
  from fastapi.middleware.cors import CORSMiddleware
32
32
  from fastapi.responses import JSONResponse, PlainTextResponse
33
- from pydantic import BaseModel, Field, field_validator
33
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
34
34
  from sqlalchemy import select, update, delete
35
35
  from sqlalchemy.ext.asyncio import AsyncSession
36
36
  from sqlalchemy.orm import selectinload
@@ -45,6 +45,7 @@ from .models import (
45
45
  Task,
46
46
  TaskPriority,
47
47
  TaskStatus,
48
+ Tenant,
48
49
  )
49
50
  from . import registry
50
51
  from . import auth
@@ -93,7 +94,7 @@ def _safe_json_read(path: _Path, default: Any = None) -> Any:
93
94
 
94
95
  def _safe_read_text(path: _Path) -> str:
95
96
  """Read a text file with UTF-8 encoding, replacing non-UTF-8 bytes."""
96
- return open(path, encoding="utf-8", errors="replace").read()
97
+ return _Path(path).read_text(encoding="utf-8", errors="replace")
97
98
 
98
99
 
99
100
  # ---------------------------------------------------------------------------
@@ -175,11 +176,13 @@ class ProjectUpdate(BaseModel):
175
176
  name: Optional[str] = Field(None, min_length=1, max_length=255)
176
177
  description: Optional[str] = None
177
178
  prd_path: Optional[str] = None
178
- status: Optional[str] = None
179
+ status: Optional[Literal["active", "archived", "completed", "paused"]] = None
179
180
 
180
181
 
181
182
  class ProjectResponse(BaseModel):
182
183
  """Schema for project response."""
184
+ model_config = ConfigDict(from_attributes=True)
185
+
183
186
  id: int
184
187
  name: str
185
188
  description: Optional[str]
@@ -190,9 +193,6 @@ class ProjectResponse(BaseModel):
190
193
  task_count: int = 0
191
194
  completed_task_count: int = 0
192
195
 
193
- class Config:
194
- from_attributes = True
195
-
196
196
 
197
197
  class TaskCreate(BaseModel):
198
198
  """Schema for creating a task."""
@@ -231,6 +231,8 @@ class TaskMove(BaseModel):
231
231
 
232
232
  class TaskResponse(BaseModel):
233
233
  """Schema for task response."""
234
+ model_config = ConfigDict(from_attributes=True)
235
+
234
236
  id: int
235
237
  project_id: int
236
238
  title: str
@@ -246,9 +248,6 @@ class TaskResponse(BaseModel):
246
248
  updated_at: datetime
247
249
  completed_at: Optional[datetime]
248
250
 
249
- class Config:
250
- from_attributes = True
251
-
252
251
 
253
252
  class SessionInfo(BaseModel):
254
253
  """Info about a single running session."""
@@ -290,15 +289,16 @@ class ConnectionManager:
290
289
  def __init__(self):
291
290
  self.active_connections: list[WebSocket] = []
292
291
 
293
- async def connect(self, websocket: WebSocket) -> None:
294
- """Accept a new WebSocket connection."""
292
+ async def connect(self, websocket: WebSocket) -> bool:
293
+ """Accept a new WebSocket connection. Returns False if rejected."""
295
294
  if len(self.active_connections) >= self.MAX_CONNECTIONS:
296
295
  await websocket.accept()
297
296
  await websocket.close(code=1013, reason="Connection limit reached. Try again later.")
298
297
  logger.warning(f"WebSocket connection rejected: limit of {self.MAX_CONNECTIONS} reached")
299
- return
298
+ return False
300
299
  await websocket.accept()
301
300
  self.active_connections.append(websocket)
301
+ return True
302
302
 
303
303
  def disconnect(self, websocket: WebSocket) -> None:
304
304
  """Remove a WebSocket connection."""
@@ -416,7 +416,12 @@ async def _push_loki_state_loop() -> None:
416
416
  async def lifespan(app: FastAPI):
417
417
  """Application lifespan handler."""
418
418
  # Startup
419
- await init_db()
419
+ try:
420
+ await init_db()
421
+ app.state.db_available = True
422
+ except Exception as exc:
423
+ logger.error("Database init failed: %s -- DB routes will return 503", exc)
424
+ app.state.db_available = False
420
425
  _telemetry.send_telemetry("dashboard_start")
421
426
  push_task = asyncio.create_task(_push_loki_state_loop())
422
427
  yield
@@ -723,50 +728,54 @@ async def list_projects(
723
728
  db: AsyncSession = Depends(get_db),
724
729
  ) -> list[ProjectResponse]:
725
730
  """List projects with pagination. Does not eager-load tasks for efficiency."""
726
- from sqlalchemy import func as sa_func
727
-
728
- query = select(Project)
729
- if status:
730
- query = query.where(Project.status == status)
731
- query = query.order_by(Project.created_at.desc()).offset(offset).limit(limit)
732
-
733
- result = await db.execute(query)
734
- projects = result.scalars().all()
735
-
736
- # Batch-fetch task counts instead of N+1 eager loading
737
- project_ids = [p.id for p in projects]
738
- response = []
739
- if project_ids:
740
- count_query = (
741
- select(
742
- Task.project_id,
743
- sa_func.count().label("total"),
744
- sa_func.count().filter(Task.status == TaskStatus.DONE).label("done"),
731
+ try:
732
+ from sqlalchemy import func as sa_func
733
+
734
+ query = select(Project)
735
+ if status:
736
+ query = query.where(Project.status == status)
737
+ query = query.order_by(Project.created_at.desc()).offset(offset).limit(limit)
738
+
739
+ result = await db.execute(query)
740
+ projects = result.scalars().all()
741
+
742
+ # Batch-fetch task counts instead of N+1 eager loading
743
+ project_ids = [p.id for p in projects]
744
+ response = []
745
+ if project_ids:
746
+ count_query = (
747
+ select(
748
+ Task.project_id,
749
+ sa_func.count().label("total"),
750
+ sa_func.count().filter(Task.status == TaskStatus.DONE).label("done"),
751
+ )
752
+ .where(Task.project_id.in_(project_ids))
753
+ .group_by(Task.project_id)
745
754
  )
746
- .where(Task.project_id.in_(project_ids))
747
- .group_by(Task.project_id)
748
- )
749
- count_result = await db.execute(count_query)
750
- counts = {row.project_id: (row.total, row.done) for row in count_result}
751
- else:
752
- counts = {}
753
-
754
- for project in projects:
755
- total, done = counts.get(project.id, (0, 0))
756
- response.append(
757
- ProjectResponse(
758
- id=project.id,
759
- name=project.name,
760
- description=project.description,
761
- prd_path=project.prd_path,
762
- status=project.status,
763
- created_at=project.created_at,
764
- updated_at=project.updated_at,
765
- task_count=total,
766
- completed_task_count=done,
755
+ count_result = await db.execute(count_query)
756
+ counts = {row.project_id: (row.total, row.done) for row in count_result}
757
+ else:
758
+ counts = {}
759
+
760
+ for project in projects:
761
+ total, done = counts.get(project.id, (0, 0))
762
+ response.append(
763
+ ProjectResponse(
764
+ id=project.id,
765
+ name=project.name,
766
+ description=project.description,
767
+ prd_path=project.prd_path,
768
+ status=project.status,
769
+ created_at=project.created_at,
770
+ updated_at=project.updated_at,
771
+ task_count=total,
772
+ completed_task_count=done,
773
+ )
767
774
  )
768
- )
769
- return response
775
+ return response
776
+ except Exception as exc:
777
+ logger.error("Failed to list projects: %s", exc, exc_info=True)
778
+ raise HTTPException(status_code=500, detail="Database query failed") from exc
770
779
 
771
780
 
772
781
  @app.post("/api/projects", response_model=ProjectResponse, status_code=201, dependencies=[Depends(auth.require_scope("control"))])
@@ -775,6 +784,13 @@ async def create_project(
775
784
  db: AsyncSession = Depends(get_db),
776
785
  ) -> ProjectResponse:
777
786
  """Create a new project."""
787
+ # Validate tenant exists
788
+ tenant_result = await db.execute(
789
+ select(Tenant).where(Tenant.id == project.tenant_id)
790
+ )
791
+ if not tenant_result.scalar_one_or_none():
792
+ raise HTTPException(status_code=404, detail="Tenant not found")
793
+
778
794
  db_project = Project(
779
795
  name=project.name,
780
796
  description=project.description,
@@ -1104,6 +1120,10 @@ async def list_tasks(
1104
1120
  except Exception:
1105
1121
  pass
1106
1122
 
1123
+ # Apply project_id filter if provided
1124
+ if project_id is not None:
1125
+ all_tasks = [t for t in all_tasks if t.get("project_id") == project_id]
1126
+
1107
1127
  # Apply status filter if provided
1108
1128
  if status:
1109
1129
  all_tasks = [t for t in all_tasks if t["status"] == status]
@@ -1217,9 +1237,12 @@ async def update_task(
1217
1237
 
1218
1238
  update_data = task_update.model_dump(exclude_unset=True)
1219
1239
 
1220
- # Handle status change to completed
1221
- if "status" in update_data and update_data["status"] == TaskStatus.DONE:
1222
- update_data["completed_at"] = datetime.now(timezone.utc)
1240
+ # Handle status change to/from completed
1241
+ if "status" in update_data:
1242
+ if update_data["status"] == TaskStatus.DONE:
1243
+ update_data["completed_at"] = datetime.now(timezone.utc)
1244
+ else:
1245
+ update_data["completed_at"] = None
1223
1246
 
1224
1247
  for field, value in update_data.items():
1225
1248
  setattr(task, field, value)
@@ -1281,6 +1304,7 @@ _TASK_STATE_MACHINE: dict[TaskStatus, set[TaskStatus]] = {
1281
1304
  TaskStatus.PENDING: {TaskStatus.IN_PROGRESS},
1282
1305
  TaskStatus.IN_PROGRESS: {TaskStatus.REVIEW, TaskStatus.DONE},
1283
1306
  TaskStatus.REVIEW: {TaskStatus.DONE, TaskStatus.IN_PROGRESS},
1307
+ TaskStatus.DONE: {TaskStatus.IN_PROGRESS, TaskStatus.REVIEW},
1284
1308
  }
1285
1309
 
1286
1310
 
@@ -1360,12 +1384,14 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
1360
1384
  import uuid as _uuid
1361
1385
  client_ip = websocket.client.host if websocket.client else f"ws-{_uuid.uuid4().hex}"
1362
1386
  if not _read_limiter.check(f"ws_{client_ip}"):
1387
+ await websocket.accept()
1363
1388
  await websocket.close(code=1008) # Policy Violation
1364
1389
  return
1365
1390
 
1366
1391
  if auth.is_enterprise_mode() or auth.is_oidc_mode():
1367
1392
  ws_token: Optional[str] = websocket.query_params.get("token")
1368
1393
  if not ws_token:
1394
+ await websocket.accept()
1369
1395
  await websocket.close(code=1008) # Policy Violation
1370
1396
  return
1371
1397
 
@@ -1378,10 +1404,13 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
1378
1404
  token_info = auth.validate_token(ws_token)
1379
1405
 
1380
1406
  if token_info is None:
1407
+ await websocket.accept()
1381
1408
  await websocket.close(code=1008) # Policy Violation
1382
1409
  return
1383
1410
 
1384
- await manager.connect(websocket)
1411
+ connected = await manager.connect(websocket)
1412
+ if not connected:
1413
+ return
1385
1414
  try:
1386
1415
  # Send initial connection confirmation
1387
1416
  await manager.send_personal(websocket, {
@@ -1424,9 +1453,10 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
1424
1453
  break
1425
1454
 
1426
1455
  except WebSocketDisconnect:
1427
- manager.disconnect(websocket)
1456
+ pass
1428
1457
  except Exception as e:
1429
1458
  logger.error(f"WebSocket error: {e}")
1459
+ finally:
1430
1460
  manager.disconnect(websocket)
1431
1461
 
1432
1462
 
@@ -1632,7 +1662,7 @@ class TokenResponse(BaseModel):
1632
1662
  token: Optional[str] = None # Only on creation
1633
1663
 
1634
1664
 
1635
- @app.post("/api/enterprise/tokens", response_model=TokenResponse, status_code=201)
1665
+ @app.post("/api/enterprise/tokens", response_model=TokenResponse, status_code=201, dependencies=[Depends(auth.require_scope("admin"))])
1636
1666
  async def create_token(request: TokenCreateRequest):
1637
1667
  """
1638
1668
  Generate a new API token (enterprise only).
@@ -1737,7 +1767,7 @@ async def query_audit_logs(
1737
1767
  resource_type: Optional[str] = None,
1738
1768
  resource_id: Optional[str] = None,
1739
1769
  limit: int = Query(default=100, ge=1, le=1000),
1740
- offset: int = 0,
1770
+ offset: int = Query(default=0, ge=0),
1741
1771
  ):
1742
1772
  """
1743
1773
  Query audit logs (enterprise only).
@@ -2133,7 +2163,7 @@ def _get_memory_storage():
2133
2163
  @app.get("/api/memory/search")
2134
2164
  async def search_memory(
2135
2165
  q: str = Query(..., min_length=1, max_length=500, description="Search query"),
2136
- collection: str = Query(default="all", regex="^(episodes|patterns|skills|all)$"),
2166
+ collection: str = Query(default="all", pattern="^(episodes|patterns|skills|all)$"),
2137
2167
  limit: int = Query(default=20, ge=1, le=100),
2138
2168
  ):
2139
2169
  """Full-text search across memory using FTS5."""
@@ -2342,7 +2372,7 @@ async def get_learning_signals(
2342
2372
  signalType: Optional[str] = None,
2343
2373
  source: Optional[str] = None,
2344
2374
  limit: int = Query(default=50, ge=1, le=1000),
2345
- offset: int = 0,
2375
+ offset: int = Query(default=0, ge=0),
2346
2376
  ):
2347
2377
  """Get raw learning signals from both events.jsonl and learning signals directory."""
2348
2378
  events = _read_events(timeRange)
@@ -2688,19 +2718,18 @@ async def pause_session():
2688
2718
  content={"success": False, "message": "Session process is not running; pause signal may have no effect"},
2689
2719
  )
2690
2720
 
2691
- # Poll up to 5s to confirm process is still alive with PAUSE file present
2692
- for _ in range(10):
2693
- try:
2694
- os.kill(pid, 0)
2695
- return {"success": True, "message": "Session paused", "process_verified": True}
2696
- except OSError:
2697
- break
2698
- await asyncio.sleep(0.5)
2699
-
2700
- return JSONResponse(
2701
- status_code=503,
2702
- content={"success": False, "message": "Session process exited unexpectedly after pause signal"},
2703
- )
2721
+ # Verify process is still alive after writing the PAUSE file.
2722
+ # Give the process a moment to notice the signal, then confirm it
2723
+ # has not exited unexpectedly.
2724
+ await asyncio.sleep(0.5)
2725
+ try:
2726
+ os.kill(pid, 0)
2727
+ return {"success": True, "message": "Session paused", "process_verified": True}
2728
+ except OSError:
2729
+ return JSONResponse(
2730
+ status_code=503,
2731
+ content={"success": False, "message": "Session process exited unexpectedly after pause signal"},
2732
+ )
2704
2733
 
2705
2734
 
2706
2735
  @app.post("/api/control/resume", dependencies=[Depends(auth.require_scope("control"))])