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.
- package/SKILL.md +3 -3
- package/VERSION +1 -1
- package/autonomy/lib/claude-flags.sh +69 -5
- package/autonomy/lib/contract-scaffold/scaffold.sh +1 -1
- package/autonomy/loki +324 -74
- package/autonomy/run.sh +176 -40
- package/autonomy/verify.sh +16 -3
- package/bin/loki +25 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/control.py +88 -4
- package/dashboard/server.py +50 -48
- package/docs/INSTALLATION.md +2 -2
- package/docs/RARV-C-100X-PLAN.md +9 -1
- package/docs/enterprise/performance.md +51 -9
- package/docs/exit-codes.md +4 -0
- package/loki-ts/dist/cockpit.js +1 -1
- package/loki-ts/dist/loki.js +634 -549
- package/loki-ts/package.json +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +6 -5
- package/plugins/loki-mode/.claude-plugin/plugin.json +2 -3
- package/providers/loader.sh +2 -2
- package/providers/model_catalog.json +26 -8
- package/providers/models.sh +1 -1
- package/providers/opencode.sh +1 -1
- package/skills/providers.md +42 -14
- package/tools/normalize-package-permissions.mjs +34 -0
- package/web-app/dist/assets/{AdminPage-DLhWX6si.js → AdminPage-PyyVXg4b.js} +1 -1
- package/web-app/dist/assets/{Avatar-CqyDcUxl.js → Avatar-CTUlr_Xf.js} +1 -1
- package/web-app/dist/assets/{Badge-BKGSt1n2.js → Badge-BQeXvmg8.js} +1 -1
- package/web-app/dist/assets/{Button-DuuKHXre.js → Button-Dc6-P6pz.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-DdClYERC.js → CockpitPage-BspOJ3Fv.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CUWlqBxv.js → ComparePage-CFXIM--Z.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-RwfGKyRJ.js → ErrorBoundary-C7vQYDW9.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-CzIgk9sg.js → EvidenceReceiptPanel-CqjsjTOs.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-CsXVKuyJ.js → GitHubIssuesPanel-DyuNDMgD.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CYRp1eKS.js → GitHubPRsPanel-BtAq20xF.js} +1 -1
- package/web-app/dist/assets/{HomePage-CVJcI5pQ.js → HomePage-BuVFOrO8.js} +1 -1
- package/web-app/dist/assets/{LoginPage-BBJNV9Qb.js → LoginPage-CuhhrxGb.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CIi9P8jK.js → MagicPage-D2KZPX3F.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-Bm34ipiy.js → MetricsPage-DFEYJl9z.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-DpTbcC2P.js → NotFoundPage-CTnnaWlL.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-CY2SMGx9.js → ProjectPage-uJFaoM8-.js} +55 -55
- package/web-app/dist/assets/{ProjectsPage-DSrXUUKJ.js → ProjectsPage-8lKAOBIW.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-jvDyOqYi.js → SettingsPage-D1BBd5qy.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-C8tFWKau.js → ShowcasePage-CmQ0n8iO.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-DH4FTH3K.js → SystemSettingsPage-CSHaVyuD.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-C1fQYauF.js → TeamsPage-BS0TtVUa.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CSTFt-4P.js → TemplatesPage-C2zPnnf2.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-Cu4NsyN6.js → TerminalOutput-Dle1klqq.js} +1 -1
- package/web-app/dist/assets/{activity-CtjsDzmh.js → activity-DQp5U9f9.js} +1 -1
- package/web-app/dist/assets/{bell-C5es1xAs.js → bell-BqkTmgL7.js} +1 -1
- package/web-app/dist/assets/{bot-DE04U5Lc.js → bot-DILhaIGH.js} +1 -1
- package/web-app/dist/assets/{check-DSKKA9EU.js → check-BC9x7oPm.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DJ13W5AR.js → chevron-left--yqvAfjx.js} +1 -1
- package/web-app/dist/assets/{circle-alert-CS0BfqAd.js → circle-alert-DOH6PcgZ.js} +1 -1
- package/web-app/dist/assets/{clock-D1Y5nruk.js → clock-DKSDFsE6.js} +1 -1
- package/web-app/dist/assets/{cloud-nU6LBoZL.js → cloud-DRBU3JYs.js} +1 -1
- package/web-app/dist/assets/{code-xml-BZfvIdFo.js → code-xml-D2LqszR9.js} +1 -1
- package/web-app/dist/assets/{copy-CK_2O1Xv.js → copy-BKW4U4Qr.js} +1 -1
- package/web-app/dist/assets/{database-DJiwznAm.js → database-D7ceedlh.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-pO20A3QY.js → dollar-sign-D0A47egY.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Besy6vek.js → file-code-corner-CH_Qbtjo.js} +1 -1
- package/web-app/dist/assets/{file-plus-DeaswxYY.js → file-plus-CUNMjHk9.js} +1 -1
- package/web-app/dist/assets/{folder-open-cr1UvQLI.js → folder-open-CQ_SVq94.js} +1 -1
- package/web-app/dist/assets/{git-commit-horizontal-BLxnfbSn.js → git-commit-horizontal-DQUBe0oD.js} +1 -1
- package/web-app/dist/assets/{globe-DrhG1NfX.js → globe-gZnaGg8N.js} +1 -1
- package/web-app/dist/assets/{hammer-CeZtPBfE.js → hammer-BhJC5DMK.js} +1 -1
- package/web-app/dist/assets/{index-CLvjO22Z.js → index-BMmYYws0.js} +2 -2
- package/web-app/dist/assets/{layers-fldADzyE.js → layers-glXeoUbJ.js} +1 -1
- package/web-app/dist/assets/{lightbulb-CznOfsPb.js → lightbulb-gXn8H0Lf.js} +1 -1
- package/web-app/dist/assets/{loader-circle---lPB1Fs.js → loader-circle-Do2ke8xm.js} +1 -1
- package/web-app/dist/assets/{lock-D5jVF-tO.js → lock-Bgplb1_n.js} +1 -1
- package/web-app/dist/assets/{mail-D7HkLnlW.js → mail-j4Lo8hfB.js} +1 -1
- package/web-app/dist/assets/{minus-C-dT19Ct.js → minus-dhrzKSK5.js} +1 -1
- package/web-app/dist/assets/{package-Cm9B-cKE.js → package-CInWQQ97.js} +1 -1
- package/web-app/dist/assets/{plus-CQwOffLD.js → plus-BpYvoLlZ.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-CvWJZLbN.js → refresh-cw-F6koqEgF.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-C2Q1LYgM.js → rotate-ccw-CQ2W5S8f.js} +1 -1
- package/web-app/dist/assets/{save-BP0MvpoQ.js → save-DMCYGm06.js} +1 -1
- package/web-app/dist/assets/{server-4i4VZXix.js → server-Bj8EX4Z1.js} +1 -1
- package/web-app/dist/assets/{shield-alert-D2_bgb7j.js → shield-alert-CiYuulCU.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-vACbzeXj.js → thumbs-up-BqEh1puP.js} +1 -1
- package/web-app/dist/assets/{trash-2-EYjdESLs.js → trash-2-CCwouZJJ.js} +1 -1
- package/web-app/dist/assets/{trending-down-BWBdcvE3.js → trending-down-Cx1vhE4o.js} +1 -1
- package/web-app/dist/assets/{trending-up-Bix6V_-Y.js → trending-up-BUhVYJ2T.js} +1 -1
- package/web-app/dist/assets/{upload-B7hh6ERF.js → upload-D5IsZeFM.js} +1 -1
- package/web-app/dist/assets/{usePolling-b_e-k-IN.js → usePolling-CJbfhFcH.js} +1 -1
- package/web-app/dist/assets/{user-pmjZKWEg.js → user-BRLhezsD.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/web-app/requirements.txt +1 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
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
|
# ---------------------------------------------------------------------------
|