loki-mode 9.22.12 → 9.24.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 (92) hide show
  1. package/SKILL.md +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/lib/claude-flags.sh +69 -5
  4. package/autonomy/lib/contract-scaffold/scaffold.sh +1 -1
  5. package/autonomy/loki +324 -74
  6. package/autonomy/run.sh +176 -40
  7. package/autonomy/verify.sh +16 -3
  8. package/bin/loki +25 -1
  9. package/dashboard/__init__.py +1 -1
  10. package/dashboard/control.py +88 -4
  11. package/dashboard/server.py +50 -48
  12. package/docs/INSTALLATION.md +2 -2
  13. package/docs/RARV-C-100X-PLAN.md +9 -1
  14. package/docs/enterprise/performance.md +51 -9
  15. package/docs/exit-codes.md +4 -0
  16. package/loki-ts/dist/cockpit.js +1 -1
  17. package/loki-ts/dist/loki.js +634 -549
  18. package/loki-ts/package.json +2 -2
  19. package/mcp/__init__.py +1 -1
  20. package/package.json +6 -5
  21. package/plugins/loki-mode/.claude-plugin/plugin.json +2 -3
  22. package/providers/loader.sh +2 -2
  23. package/providers/model_catalog.json +26 -8
  24. package/providers/models.sh +1 -1
  25. package/providers/opencode.sh +1 -1
  26. package/skills/providers.md +42 -14
  27. package/tools/normalize-package-permissions.mjs +34 -0
  28. package/web-app/dist/assets/{AdminPage-DLhWX6si.js → AdminPage-PyyVXg4b.js} +1 -1
  29. package/web-app/dist/assets/{Avatar-CqyDcUxl.js → Avatar-CTUlr_Xf.js} +1 -1
  30. package/web-app/dist/assets/{Badge-BKGSt1n2.js → Badge-BQeXvmg8.js} +1 -1
  31. package/web-app/dist/assets/{Button-DuuKHXre.js → Button-Dc6-P6pz.js} +1 -1
  32. package/web-app/dist/assets/{CockpitPage-DdClYERC.js → CockpitPage-BspOJ3Fv.js} +1 -1
  33. package/web-app/dist/assets/{ComparePage-CUWlqBxv.js → ComparePage-CFXIM--Z.js} +1 -1
  34. package/web-app/dist/assets/{ErrorBoundary-RwfGKyRJ.js → ErrorBoundary-C7vQYDW9.js} +1 -1
  35. package/web-app/dist/assets/{EvidenceReceiptPanel-CzIgk9sg.js → EvidenceReceiptPanel-CqjsjTOs.js} +1 -1
  36. package/web-app/dist/assets/{GitHubIssuesPanel-CsXVKuyJ.js → GitHubIssuesPanel-DyuNDMgD.js} +1 -1
  37. package/web-app/dist/assets/{GitHubPRsPanel-CYRp1eKS.js → GitHubPRsPanel-BtAq20xF.js} +1 -1
  38. package/web-app/dist/assets/{HomePage-CVJcI5pQ.js → HomePage-BuVFOrO8.js} +1 -1
  39. package/web-app/dist/assets/{LoginPage-BBJNV9Qb.js → LoginPage-CuhhrxGb.js} +1 -1
  40. package/web-app/dist/assets/{MagicPage-CIi9P8jK.js → MagicPage-D2KZPX3F.js} +1 -1
  41. package/web-app/dist/assets/{MetricsPage-Bm34ipiy.js → MetricsPage-DFEYJl9z.js} +1 -1
  42. package/web-app/dist/assets/{NotFoundPage-DpTbcC2P.js → NotFoundPage-CTnnaWlL.js} +1 -1
  43. package/web-app/dist/assets/{ProjectPage-CY2SMGx9.js → ProjectPage-uJFaoM8-.js} +55 -55
  44. package/web-app/dist/assets/{ProjectsPage-DSrXUUKJ.js → ProjectsPage-8lKAOBIW.js} +1 -1
  45. package/web-app/dist/assets/{SettingsPage-jvDyOqYi.js → SettingsPage-D1BBd5qy.js} +1 -1
  46. package/web-app/dist/assets/{ShowcasePage-C8tFWKau.js → ShowcasePage-CmQ0n8iO.js} +1 -1
  47. package/web-app/dist/assets/{SystemSettingsPage-DH4FTH3K.js → SystemSettingsPage-CSHaVyuD.js} +1 -1
  48. package/web-app/dist/assets/{TeamsPage-C1fQYauF.js → TeamsPage-BS0TtVUa.js} +1 -1
  49. package/web-app/dist/assets/{TemplatesPage-CSTFt-4P.js → TemplatesPage-C2zPnnf2.js} +1 -1
  50. package/web-app/dist/assets/{TerminalOutput-Cu4NsyN6.js → TerminalOutput-Dle1klqq.js} +1 -1
  51. package/web-app/dist/assets/{activity-CtjsDzmh.js → activity-DQp5U9f9.js} +1 -1
  52. package/web-app/dist/assets/{bell-C5es1xAs.js → bell-BqkTmgL7.js} +1 -1
  53. package/web-app/dist/assets/{bot-DE04U5Lc.js → bot-DILhaIGH.js} +1 -1
  54. package/web-app/dist/assets/{check-DSKKA9EU.js → check-BC9x7oPm.js} +1 -1
  55. package/web-app/dist/assets/{chevron-left-DJ13W5AR.js → chevron-left--yqvAfjx.js} +1 -1
  56. package/web-app/dist/assets/{circle-alert-CS0BfqAd.js → circle-alert-DOH6PcgZ.js} +1 -1
  57. package/web-app/dist/assets/{clock-D1Y5nruk.js → clock-DKSDFsE6.js} +1 -1
  58. package/web-app/dist/assets/{cloud-nU6LBoZL.js → cloud-DRBU3JYs.js} +1 -1
  59. package/web-app/dist/assets/{code-xml-BZfvIdFo.js → code-xml-D2LqszR9.js} +1 -1
  60. package/web-app/dist/assets/{copy-CK_2O1Xv.js → copy-BKW4U4Qr.js} +1 -1
  61. package/web-app/dist/assets/{database-DJiwznAm.js → database-D7ceedlh.js} +1 -1
  62. package/web-app/dist/assets/{dollar-sign-pO20A3QY.js → dollar-sign-D0A47egY.js} +1 -1
  63. package/web-app/dist/assets/{file-code-corner-Besy6vek.js → file-code-corner-CH_Qbtjo.js} +1 -1
  64. package/web-app/dist/assets/{file-plus-DeaswxYY.js → file-plus-CUNMjHk9.js} +1 -1
  65. package/web-app/dist/assets/{folder-open-cr1UvQLI.js → folder-open-CQ_SVq94.js} +1 -1
  66. package/web-app/dist/assets/{git-commit-horizontal-BLxnfbSn.js → git-commit-horizontal-DQUBe0oD.js} +1 -1
  67. package/web-app/dist/assets/{globe-DrhG1NfX.js → globe-gZnaGg8N.js} +1 -1
  68. package/web-app/dist/assets/{hammer-CeZtPBfE.js → hammer-BhJC5DMK.js} +1 -1
  69. package/web-app/dist/assets/{index-CLvjO22Z.js → index-BMmYYws0.js} +2 -2
  70. package/web-app/dist/assets/{layers-fldADzyE.js → layers-glXeoUbJ.js} +1 -1
  71. package/web-app/dist/assets/{lightbulb-CznOfsPb.js → lightbulb-gXn8H0Lf.js} +1 -1
  72. package/web-app/dist/assets/{loader-circle---lPB1Fs.js → loader-circle-Do2ke8xm.js} +1 -1
  73. package/web-app/dist/assets/{lock-D5jVF-tO.js → lock-Bgplb1_n.js} +1 -1
  74. package/web-app/dist/assets/{mail-D7HkLnlW.js → mail-j4Lo8hfB.js} +1 -1
  75. package/web-app/dist/assets/{minus-C-dT19Ct.js → minus-dhrzKSK5.js} +1 -1
  76. package/web-app/dist/assets/{package-Cm9B-cKE.js → package-CInWQQ97.js} +1 -1
  77. package/web-app/dist/assets/{plus-CQwOffLD.js → plus-BpYvoLlZ.js} +1 -1
  78. package/web-app/dist/assets/{refresh-cw-CvWJZLbN.js → refresh-cw-F6koqEgF.js} +1 -1
  79. package/web-app/dist/assets/{rotate-ccw-C2Q1LYgM.js → rotate-ccw-CQ2W5S8f.js} +1 -1
  80. package/web-app/dist/assets/{save-BP0MvpoQ.js → save-DMCYGm06.js} +1 -1
  81. package/web-app/dist/assets/{server-4i4VZXix.js → server-Bj8EX4Z1.js} +1 -1
  82. package/web-app/dist/assets/{shield-alert-D2_bgb7j.js → shield-alert-CiYuulCU.js} +1 -1
  83. package/web-app/dist/assets/{thumbs-up-vACbzeXj.js → thumbs-up-BqEh1puP.js} +1 -1
  84. package/web-app/dist/assets/{trash-2-EYjdESLs.js → trash-2-CCwouZJJ.js} +1 -1
  85. package/web-app/dist/assets/{trending-down-BWBdcvE3.js → trending-down-Cx1vhE4o.js} +1 -1
  86. package/web-app/dist/assets/{trending-up-Bix6V_-Y.js → trending-up-BUhVYJ2T.js} +1 -1
  87. package/web-app/dist/assets/{upload-B7hh6ERF.js → upload-D5IsZeFM.js} +1 -1
  88. package/web-app/dist/assets/{usePolling-b_e-k-IN.js → usePolling-CJbfhFcH.js} +1 -1
  89. package/web-app/dist/assets/{user-pmjZKWEg.js → user-BRLhezsD.js} +1 -1
  90. package/web-app/dist/index.html +1 -1
  91. package/web-app/requirements.txt +1 -0
  92. package/web-app/server.py +160 -31
package/web-app/server.py CHANGED
@@ -34,6 +34,8 @@ from datetime import datetime
34
34
  from fastapi import Body, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
35
35
  from fastapi.middleware.cors import CORSMiddleware
36
36
  from fastapi.responses import FileResponse, JSONResponse, Response
37
+ from starlette.formparsers import MultiPartException, MultiPartParser
38
+ from starlette.requests import ClientDisconnect
37
39
  from starlette.responses import StreamingResponse
38
40
  from fastapi.staticfiles import StaticFiles
39
41
  import shlex
@@ -5684,6 +5686,134 @@ async def git_create_pr(session_id: str, req: dict = Body(...)) -> JSONResponse:
5684
5686
  # Image upload for AI chat (screenshot-to-change)
5685
5687
  # ---------------------------------------------------------------------------
5686
5688
 
5689
+ _CHAT_IMAGE_MAX_BYTES = 10 * 1024 * 1024
5690
+ _CHAT_IMAGE_READ_BYTES = 1024 * 1024
5691
+ _CHAT_IMAGE_MULTIPART_OVERHEAD_BYTES = 64 * 1024
5692
+ _CHAT_IMAGE_MAX_REQUEST_BYTES = _CHAT_IMAGE_MAX_BYTES + _CHAT_IMAGE_MULTIPART_OVERHEAD_BYTES
5693
+ _CHAT_IMAGE_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
5694
+
5695
+
5696
+ class _ChatImageBodyTooLarge(Exception):
5697
+ pass
5698
+
5699
+
5700
+ async def _parse_bounded_chat_image_form(request: Request):
5701
+ """Parse one multipart upload while bounding bytes and owning temp cleanup."""
5702
+ received = 0
5703
+
5704
+ async def bounded_stream():
5705
+ nonlocal received
5706
+ async for chunk in request.stream():
5707
+ received += len(chunk)
5708
+ if received > _CHAT_IMAGE_MAX_REQUEST_BYTES:
5709
+ raise _ChatImageBodyTooLarge
5710
+ yield chunk
5711
+
5712
+ parser = MultiPartParser(
5713
+ headers=request.headers,
5714
+ stream=bounded_stream(),
5715
+ max_files=1,
5716
+ max_fields=1000,
5717
+ )
5718
+ try:
5719
+ return await parser.parse()
5720
+ except BaseException:
5721
+ # Starlette closes these files for MultiPartException only. Transport
5722
+ # disconnects and receive errors must have the same deterministic
5723
+ # cleanup rather than relying on interpreter-specific destruction.
5724
+ for upload_temp in parser._files_to_close_on_error:
5725
+ upload_temp.close()
5726
+ raise
5727
+
5728
+
5729
+ def _chat_image_matches_type(content: bytes, content_type: str) -> bool:
5730
+ if content_type == "image/png":
5731
+ if not content.startswith(b"\x89PNG\r\n\x1a\n"):
5732
+ return False
5733
+ offset = 8
5734
+ first_chunk = True
5735
+ while offset + 12 <= len(content):
5736
+ chunk_size = int.from_bytes(content[offset:offset + 4], "big")
5737
+ chunk_type = content[offset + 4:offset + 8]
5738
+ chunk_end = offset + 12 + chunk_size
5739
+ if chunk_end > len(content):
5740
+ return False
5741
+ if first_chunk and chunk_type != b"IHDR":
5742
+ return False
5743
+ first_chunk = False
5744
+ if chunk_type == b"IEND":
5745
+ return chunk_size == 0 and chunk_end == len(content)
5746
+ offset = chunk_end
5747
+ return False
5748
+ if content_type == "image/jpeg":
5749
+ return (
5750
+ len(content) >= 5
5751
+ and content.startswith(b"\xff\xd8\xff")
5752
+ and content.endswith(b"\xff\xd9")
5753
+ )
5754
+ if content_type == "image/gif":
5755
+ return (
5756
+ len(content) >= 14
5757
+ and content.startswith((b"GIF87a", b"GIF89a"))
5758
+ and content.endswith(b"\x3b")
5759
+ )
5760
+ if content_type == "image/webp":
5761
+ return (
5762
+ len(content) >= 20
5763
+ and content[:4] == b"RIFF"
5764
+ and content[8:12] == b"WEBP"
5765
+ and int.from_bytes(content[4:8], "little") == len(content) - 8
5766
+ )
5767
+ return False
5768
+
5769
+
5770
+ async def _store_chat_image(target: Path, image_file) -> JSONResponse:
5771
+ content_type = (getattr(image_file, "content_type", "") or "").lower()
5772
+ if content_type not in _CHAT_IMAGE_TYPES:
5773
+ return JSONResponse(
5774
+ status_code=415,
5775
+ content={"error": "Unsupported image type. Use PNG, JPEG, GIF, or WebP"},
5776
+ )
5777
+
5778
+ chunks = []
5779
+ total_bytes = 0
5780
+ while True:
5781
+ chunk = await image_file.read(_CHAT_IMAGE_READ_BYTES)
5782
+ if not chunk:
5783
+ break
5784
+ total_bytes += len(chunk)
5785
+ if total_bytes > _CHAT_IMAGE_MAX_BYTES:
5786
+ return JSONResponse(
5787
+ status_code=413,
5788
+ content={"error": "Image too large (max 10MB)"},
5789
+ )
5790
+ chunks.append(chunk)
5791
+ content = b"".join(chunks)
5792
+ if not _chat_image_matches_type(content, content_type):
5793
+ return JSONResponse(
5794
+ status_code=415,
5795
+ content={"error": "Image content is invalid, incomplete, or does not match its declared type"},
5796
+ )
5797
+
5798
+ images_dir = target / ".loki" / "images"
5799
+ images_dir.mkdir(parents=True, exist_ok=True)
5800
+
5801
+ image_id = str(uuid.uuid4())[:8]
5802
+ original_name = getattr(image_file, "filename", "image.png") or "image.png"
5803
+ safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", original_name)
5804
+ saved_name = f"{image_id}_{safe_name}"
5805
+ saved_path = images_dir / saved_name
5806
+
5807
+ with open(saved_path, "wb") as output:
5808
+ output.write(content)
5809
+
5810
+ return JSONResponse(content={
5811
+ "image_id": image_id,
5812
+ "filename": safe_name,
5813
+ "path": str(saved_path.relative_to(target)),
5814
+ "size": len(content),
5815
+ })
5816
+
5687
5817
 
5688
5818
  @app.post("/api/sessions/{session_id}/chat/image")
5689
5819
  async def chat_image_upload(session_id: str, request: Request) -> JSONResponse:
@@ -5694,41 +5824,40 @@ async def chat_image_upload(session_id: str, request: Request) -> JSONResponse:
5694
5824
  if target is None:
5695
5825
  return JSONResponse(status_code=404, content={"error": "Session not found"})
5696
5826
 
5697
- # Parse multipart form data
5827
+ raw_content_length = request.headers.get("content-length")
5828
+ if raw_content_length is not None:
5829
+ try:
5830
+ declared_bytes = int(raw_content_length, 10)
5831
+ except (TypeError, ValueError):
5832
+ return JSONResponse(status_code=400, content={"error": "Invalid Content-Length"})
5833
+ if declared_bytes <= 0:
5834
+ return JSONResponse(status_code=400, content={"error": "Invalid Content-Length"})
5835
+ if declared_bytes > _CHAT_IMAGE_MAX_REQUEST_BYTES:
5836
+ return JSONResponse(status_code=413, content={"error": "Image too large (max 10MB)"})
5837
+
5838
+ form = None
5698
5839
  try:
5699
- form = await request.form()
5840
+ form = await _parse_bounded_chat_image_form(request)
5700
5841
  image_file = form.get("image")
5701
5842
  if image_file is None:
5702
5843
  return JSONResponse(status_code=400, content={"error": "No image file provided"})
5703
-
5704
- # Read file content
5705
- content = await image_file.read()
5706
- if len(content) > 10 * 1024 * 1024: # 10MB limit
5707
- return JSONResponse(status_code=400, content={"error": "Image too large (max 10MB)"})
5708
-
5709
- # Save to session's .loki/images/ directory
5710
- images_dir = target / ".loki" / "images"
5711
- images_dir.mkdir(parents=True, exist_ok=True)
5712
-
5713
- image_id = str(uuid.uuid4())[:8]
5714
- # Sanitize filename
5715
- original_name = getattr(image_file, 'filename', 'image.png') or 'image.png'
5716
- safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', original_name)
5717
- saved_name = f"{image_id}_{safe_name}"
5718
- saved_path = images_dir / saved_name
5719
-
5720
- with open(saved_path, "wb") as f:
5721
- f.write(content)
5722
-
5723
- return JSONResponse(content={
5724
- "image_id": image_id,
5725
- "filename": safe_name,
5726
- "path": str(saved_path.relative_to(target)),
5727
- "size": len(content),
5728
- })
5729
- except Exception as exc:
5730
- logger.error("Image upload failed: %s", exc, exc_info=True)
5731
- return JSONResponse(status_code=500, content={"error": f"Upload failed: {exc}"})
5844
+ return await _store_chat_image(target, image_file)
5845
+ except _ChatImageBodyTooLarge:
5846
+ return JSONResponse(status_code=413, content={"error": "Image too large (max 10MB)"})
5847
+ except (ClientDisconnect, OSError):
5848
+ logger.info("Image upload transport ended before completion")
5849
+ return JSONResponse(status_code=400, content={"error": "Image upload was interrupted"})
5850
+ except MultiPartException:
5851
+ return JSONResponse(status_code=400, content={"error": "Invalid multipart image upload"})
5852
+ except Exception:
5853
+ logger.error("Image upload failed", exc_info=True)
5854
+ return JSONResponse(status_code=500, content={"error": "Image upload failed"})
5855
+ finally:
5856
+ if form is not None:
5857
+ try:
5858
+ await form.close()
5859
+ except Exception:
5860
+ logger.warning("Image upload temporary-file cleanup failed", exc_info=True)
5732
5861
 
5733
5862
 
5734
5863
  # ---------------------------------------------------------------------------