Create App.py
Browse files
App.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ast
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
import shlex
|
| 7 |
+
import sqlite3
|
| 8 |
+
import subprocess
|
| 9 |
+
import tempfile
|
| 10 |
+
import time
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, List, Optional
|
| 14 |
+
|
| 15 |
+
from fastapi import FastAPI, HTTPException
|
| 16 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
+
from fastapi.responses import PlainTextResponse
|
| 18 |
+
from pydantic import BaseModel, Field
|
| 19 |
+
|
| 20 |
+
APP_NAME = "GPT Tool Terminal HF Space"
|
| 21 |
+
APP_VERSION = "2.0.0"
|
| 22 |
+
ROOT = Path(os.environ.get("DATA_ROOT", "/data")).resolve()
|
| 23 |
+
WORKSPACE = (ROOT / "workspace").resolve()
|
| 24 |
+
LEDGER_DIR = (ROOT / "ledger").resolve()
|
| 25 |
+
DB_PATH = LEDGER_DIR / "tool_memory.sqlite3"
|
| 26 |
+
MAX_OUTPUT_CHARS = int(os.environ.get("MAX_OUTPUT_CHARS", "12000"))
|
| 27 |
+
MAX_FILE_BYTES = int(os.environ.get("MAX_FILE_BYTES", str(2_000_000)))
|
| 28 |
+
DEFAULT_TIMEOUT = int(os.environ.get("DEFAULT_TIMEOUT", "8"))
|
| 29 |
+
MAX_TIMEOUT = int(os.environ.get("MAX_TIMEOUT", "20"))
|
| 30 |
+
|
| 31 |
+
WORKSPACE.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
LEDGER_DIR.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
app = FastAPI(
|
| 35 |
+
title="GPT Tool Terminal HF Space",
|
| 36 |
+
version=APP_VERSION,
|
| 37 |
+
description=(
|
| 38 |
+
"No-key GPT Actions backend for external self-learning memory, constrained terminal execution, "
|
| 39 |
+
"Python experiments, artifact storage, file IO, and hash receipts. This Space intentionally avoids "
|
| 40 |
+
"unrestricted public shell execution. Commands run inside /data/workspace with timeouts, output caps, "
|
| 41 |
+
"path containment, and a conservative command policy."
|
| 42 |
+
),
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
app.add_middleware(
|
| 46 |
+
CORSMiddleware,
|
| 47 |
+
allow_origins=["*"],
|
| 48 |
+
allow_credentials=False,
|
| 49 |
+
allow_methods=["*"],
|
| 50 |
+
allow_headers=["*"],
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def now_iso() -> str:
|
| 55 |
+
return datetime.now(timezone.utc).isoformat()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def sha256_text(text: str) -> str:
|
| 59 |
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def clamp_output(s: str) -> str:
|
| 63 |
+
if len(s) <= MAX_OUTPUT_CHARS:
|
| 64 |
+
return s
|
| 65 |
+
return s[:MAX_OUTPUT_CHARS] + f"\n\n[TRUNCATED: output exceeded {MAX_OUTPUT_CHARS} chars]"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def ensure_relative_path(path: str) -> Path:
|
| 69 |
+
if not path or path.strip() in {"/", ".", ".."}:
|
| 70 |
+
raise HTTPException(status_code=400, detail="Path must be a non-root relative workspace path.")
|
| 71 |
+
candidate = (WORKSPACE / path).resolve()
|
| 72 |
+
if not str(candidate).startswith(str(WORKSPACE)):
|
| 73 |
+
raise HTTPException(status_code=400, detail="Path escapes workspace containment.")
|
| 74 |
+
return candidate
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def init_db() -> None:
|
| 78 |
+
with sqlite3.connect(DB_PATH) as con:
|
| 79 |
+
con.execute("PRAGMA journal_mode=WAL")
|
| 80 |
+
con.execute(
|
| 81 |
+
"""
|
| 82 |
+
CREATE TABLE IF NOT EXISTS ledger (
|
| 83 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 84 |
+
ts TEXT NOT NULL,
|
| 85 |
+
kind TEXT NOT NULL,
|
| 86 |
+
summary TEXT NOT NULL,
|
| 87 |
+
payload_json TEXT NOT NULL,
|
| 88 |
+
content_hash TEXT NOT NULL
|
| 89 |
+
)
|
| 90 |
+
"""
|
| 91 |
+
)
|
| 92 |
+
con.execute(
|
| 93 |
+
"""
|
| 94 |
+
CREATE TABLE IF NOT EXISTS memory_packets (
|
| 95 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 96 |
+
ts TEXT NOT NULL,
|
| 97 |
+
source_kind TEXT NOT NULL,
|
| 98 |
+
title TEXT NOT NULL,
|
| 99 |
+
packet TEXT NOT NULL,
|
| 100 |
+
tags TEXT NOT NULL,
|
| 101 |
+
utility REAL NOT NULL,
|
| 102 |
+
payload_json TEXT NOT NULL,
|
| 103 |
+
content_hash TEXT NOT NULL
|
| 104 |
+
)
|
| 105 |
+
"""
|
| 106 |
+
)
|
| 107 |
+
con.commit()
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
init_db()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def ledger(kind: str, summary: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 114 |
+
body = {"ts": now_iso(), "kind": kind, "summary": summary, "payload": payload}
|
| 115 |
+
content_hash = sha256_text(json.dumps(body, sort_keys=True, ensure_ascii=False))
|
| 116 |
+
with sqlite3.connect(DB_PATH) as con:
|
| 117 |
+
cur = con.execute(
|
| 118 |
+
"INSERT INTO ledger(ts, kind, summary, payload_json, content_hash) VALUES (?, ?, ?, ?, ?)",
|
| 119 |
+
(body["ts"], kind, summary[:500], json.dumps(payload, ensure_ascii=False), content_hash),
|
| 120 |
+
)
|
| 121 |
+
row_id = cur.lastrowid
|
| 122 |
+
con.commit()
|
| 123 |
+
return {"receipt_id": str(row_id), "content_hash": content_hash, "ts": body["ts"]}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
DANGEROUS_PATTERNS = [
|
| 127 |
+
r"\brm\b.*\-r", r"\brm\b.*\-f", r"\bmkfs\b", r"\bdd\b", r"\bshutdown\b", r"\breboot\b",
|
| 128 |
+
r"\bmount\b", r"\bumount\b", r"\bsudo\b", r"\bsu\b", r"\bchmod\b.*777", r"\bchown\b",
|
| 129 |
+
r"/etc/passwd", r"/etc/shadow", r"/proc/", r"/sys/", r"/dev/", r"\bssh\b", r"\bscp\b",
|
| 130 |
+
r"\bnc\b", r"\bnetcat\b", r"\btelnet\b", r"\bcurl\b", r"\bwget\b", r"\bpython\b.*socket",
|
| 131 |
+
r"\bbase64\b.*\-d", r"\beval\b", r"\bexec\b.*\$", r"\bwhile\s+true\b", r":\(\)\s*\{",
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
SAFE_FIRST_TOKENS = {
|
| 135 |
+
"pwd", "ls", "find", "cat", "head", "tail", "wc", "grep", "egrep", "fgrep", "sed", "awk", "sort", "uniq",
|
| 136 |
+
"date", "echo", "printf", "cut", "tr", "du", "stat", "file", "sqlite3", "git", "python", "python3", "bash", "sh",
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def validate_command(command: str) -> None:
|
| 141 |
+
if not command or len(command) > 2000:
|
| 142 |
+
raise HTTPException(status_code=400, detail="Command is empty or too long.")
|
| 143 |
+
lowered = command.lower()
|
| 144 |
+
for pat in DANGEROUS_PATTERNS:
|
| 145 |
+
if re.search(pat, lowered):
|
| 146 |
+
raise HTTPException(status_code=400, detail=f"Command rejected by safety policy: {pat}")
|
| 147 |
+
try:
|
| 148 |
+
tokens = shlex.split(command)
|
| 149 |
+
except ValueError as e:
|
| 150 |
+
raise HTTPException(status_code=400, detail=f"Invalid shell syntax: {e}")
|
| 151 |
+
if not tokens:
|
| 152 |
+
raise HTTPException(status_code=400, detail="Command has no tokens.")
|
| 153 |
+
first = Path(tokens[0]).name
|
| 154 |
+
if first not in SAFE_FIRST_TOKENS:
|
| 155 |
+
raise HTTPException(status_code=400, detail=f"Command '{first}' is not in the allowed tool set.")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
PY_DENY_SUBSTRINGS = [
|
| 159 |
+
"import os", "from os", "import subprocess", "from subprocess", "import socket", "from socket", "import pathlib",
|
| 160 |
+
"open('/", "open(\"/", "__import__", "eval(", "exec(", "compile(", "input(", "requests", "urllib", "httpx",
|
| 161 |
+
"shutil", "pickle", "marshal", "ctypes", "multiprocessing", "threading", "asyncio", "pty", "sys.modules",
|
| 162 |
+
]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def validate_python(code: str) -> None:
|
| 166 |
+
if not code or len(code) > 20_000:
|
| 167 |
+
raise HTTPException(status_code=400, detail="Python code is empty or too long.")
|
| 168 |
+
lowered = code.lower()
|
| 169 |
+
for s in PY_DENY_SUBSTRINGS:
|
| 170 |
+
if s in lowered:
|
| 171 |
+
raise HTTPException(status_code=400, detail=f"Python rejected by safety policy: {s}")
|
| 172 |
+
try:
|
| 173 |
+
tree = ast.parse(code)
|
| 174 |
+
except SyntaxError as e:
|
| 175 |
+
raise HTTPException(status_code=400, detail=f"Python syntax error: {e}")
|
| 176 |
+
for node in ast.walk(tree):
|
| 177 |
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
| 178 |
+
names = [alias.name.split(".")[0] for alias in getattr(node, "names", [])]
|
| 179 |
+
if isinstance(node, ast.ImportFrom) and node.module:
|
| 180 |
+
names.append(node.module.split(".")[0])
|
| 181 |
+
blocked = {"os", "subprocess", "socket", "pathlib", "shutil", "requests", "urllib", "httpx", "pickle", "marshal", "ctypes", "sys"}
|
| 182 |
+
hit = blocked.intersection(names)
|
| 183 |
+
if hit:
|
| 184 |
+
raise HTTPException(status_code=400, detail=f"Blocked import(s): {', '.join(sorted(hit))}")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class IngestRequest(BaseModel):
|
| 188 |
+
kind: str = Field(..., description="Event class: correction, artifact, decision, benchmark, repo_event, user_preference, failure, success, note.")
|
| 189 |
+
summary: str = Field(..., description="Short reusable summary.")
|
| 190 |
+
payload: Dict[str, Any] = Field(default_factory=dict, description="Raw structured evidence.")
|
| 191 |
+
tags: List[str] = Field(default_factory=list)
|
| 192 |
+
utility: float = Field(default=0.5, ge=0, le=1)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class LearnRequest(BaseModel):
|
| 196 |
+
title: str
|
| 197 |
+
source_kind: str = "note"
|
| 198 |
+
observation: str
|
| 199 |
+
reusable_rule: str
|
| 200 |
+
evidence: Dict[str, Any] = Field(default_factory=dict)
|
| 201 |
+
tags: List[str] = Field(default_factory=list)
|
| 202 |
+
utility: float = Field(default=0.7, ge=0, le=1)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class RecallRequest(BaseModel):
|
| 206 |
+
query: str
|
| 207 |
+
tags: List[str] = Field(default_factory=list)
|
| 208 |
+
limit: int = Field(default=8, ge=1, le=30)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class TerminalRequest(BaseModel):
|
| 212 |
+
command: str = Field(..., description="Constrained shell command executed in /data/workspace. No unrestricted RCE, no network fetch tools, no destructive filesystem commands.")
|
| 213 |
+
cwd: str = Field(default=".", description="Relative workspace directory.")
|
| 214 |
+
timeout_seconds: int = Field(default=DEFAULT_TIMEOUT, ge=1, le=MAX_TIMEOUT)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class PythonRunRequest(BaseModel):
|
| 218 |
+
code: str = Field(..., description="Constrained Python experiment executed as an isolated subprocess in /data/workspace.")
|
| 219 |
+
timeout_seconds: int = Field(default=DEFAULT_TIMEOUT, ge=1, le=MAX_TIMEOUT)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class WriteFileRequest(BaseModel):
|
| 223 |
+
path: str
|
| 224 |
+
content: str
|
| 225 |
+
overwrite: bool = True
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class ReadFileRequest(BaseModel):
|
| 229 |
+
path: str
|
| 230 |
+
max_bytes: int = Field(default=200_000, ge=1, le=MAX_FILE_BYTES)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class ListFilesRequest(BaseModel):
|
| 234 |
+
path: str = "."
|
| 235 |
+
max_results: int = Field(default=200, ge=1, le=1000)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class ArtifactRequest(BaseModel):
|
| 239 |
+
title: str
|
| 240 |
+
artifact_type: str = Field(..., description="memo, spec, prompt, report, schema, receipt, runbook, readme, dataset_note.")
|
| 241 |
+
body: str
|
| 242 |
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@app.get("/", operation_id="root")
|
| 246 |
+
def root() -> Dict[str, Any]:
|
| 247 |
+
return {
|
| 248 |
+
"name": APP_NAME,
|
| 249 |
+
"version": APP_VERSION,
|
| 250 |
+
"openapi": "/openapi.json",
|
| 251 |
+
"workspace": str(WORKSPACE),
|
| 252 |
+
"no_api_key": True,
|
| 253 |
+
"tools": [
|
| 254 |
+
"/terminal/run", "/python/run", "/files/write", "/files/read", "/files/list",
|
| 255 |
+
"/learn", "/recall", "/artifact/compile", "/ledger/recent", "/receipt/{receipt_id}",
|
| 256 |
+
],
|
| 257 |
+
"safety_boundary": "No unrestricted public shell. Execution is workspace-contained, timed, output-capped, and policy-filtered.",
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@app.get("/health", operation_id="health")
|
| 262 |
+
def health() -> Dict[str, Any]:
|
| 263 |
+
return {
|
| 264 |
+
"ok": True,
|
| 265 |
+
"ts": now_iso(),
|
| 266 |
+
"version": APP_VERSION,
|
| 267 |
+
"workspace_exists": WORKSPACE.exists(),
|
| 268 |
+
"db_exists": DB_PATH.exists(),
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@app.post("/ingest", operation_id="ingestLearningEvent")
|
| 273 |
+
def ingest(req: IngestRequest) -> Dict[str, Any]:
|
| 274 |
+
receipt = ledger("ingest:" + req.kind, req.summary, req.model_dump())
|
| 275 |
+
packet_text = f"[{req.kind}] {req.summary}\nTags: {', '.join(req.tags)}\nPayload: {json.dumps(req.payload, ensure_ascii=False)[:4000]}"
|
| 276 |
+
content_hash = sha256_text(packet_text)
|
| 277 |
+
with sqlite3.connect(DB_PATH) as con:
|
| 278 |
+
cur = con.execute(
|
| 279 |
+
"INSERT INTO memory_packets(ts, source_kind, title, packet, tags, utility, payload_json, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 280 |
+
(now_iso(), req.kind, req.summary[:180], packet_text, json.dumps(req.tags), req.utility, json.dumps(req.payload, ensure_ascii=False), content_hash),
|
| 281 |
+
)
|
| 282 |
+
packet_id = cur.lastrowid
|
| 283 |
+
con.commit()
|
| 284 |
+
return {"ok": True, "packet_id": packet_id, **receipt}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@app.post("/learn", operation_id="learnReusableMemory")
|
| 288 |
+
def learn(req: LearnRequest) -> Dict[str, Any]:
|
| 289 |
+
packet = (
|
| 290 |
+
f"TITLE: {req.title}\n"
|
| 291 |
+
f"SOURCE: {req.source_kind}\n"
|
| 292 |
+
f"OBSERVATION: {req.observation}\n"
|
| 293 |
+
f"REUSABLE_RULE: {req.reusable_rule}\n"
|
| 294 |
+
f"TAGS: {', '.join(req.tags)}\n"
|
| 295 |
+
f"EVIDENCE: {json.dumps(req.evidence, ensure_ascii=False)[:5000]}"
|
| 296 |
+
)
|
| 297 |
+
content_hash = sha256_text(packet)
|
| 298 |
+
with sqlite3.connect(DB_PATH) as con:
|
| 299 |
+
cur = con.execute(
|
| 300 |
+
"INSERT INTO memory_packets(ts, source_kind, title, packet, tags, utility, payload_json, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 301 |
+
(now_iso(), req.source_kind, req.title[:180], packet, json.dumps(req.tags), req.utility, json.dumps(req.evidence, ensure_ascii=False), content_hash),
|
| 302 |
+
)
|
| 303 |
+
packet_id = cur.lastrowid
|
| 304 |
+
con.commit()
|
| 305 |
+
receipt = ledger("learn", req.title, {"packet_id": packet_id, **req.model_dump()})
|
| 306 |
+
return {"ok": True, "packet_id": packet_id, "content_hash": content_hash, **receipt}
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
@app.post("/recall", operation_id="recallRelevantMemory")
|
| 310 |
+
def recall(req: RecallRequest) -> Dict[str, Any]:
|
| 311 |
+
q_terms = [t.lower() for t in re.findall(r"[a-zA-Z0-9_\-]{3,}", req.query)][:80]
|
| 312 |
+
tag_terms = [t.lower() for t in req.tags]
|
| 313 |
+
rows = []
|
| 314 |
+
with sqlite3.connect(DB_PATH) as con:
|
| 315 |
+
con.row_factory = sqlite3.Row
|
| 316 |
+
for row in con.execute("SELECT * FROM memory_packets ORDER BY utility DESC, id DESC LIMIT 500"):
|
| 317 |
+
hay = (row["title"] + " " + row["packet"] + " " + row["tags"]).lower()
|
| 318 |
+
lexical = sum(1 for t in q_terms if t in hay)
|
| 319 |
+
tag_score = sum(2 for t in tag_terms if t in hay)
|
| 320 |
+
score = float(row["utility"]) * 10 + lexical + tag_score
|
| 321 |
+
if score > 0:
|
| 322 |
+
rows.append({
|
| 323 |
+
"id": row["id"], "ts": row["ts"], "title": row["title"], "source_kind": row["source_kind"],
|
| 324 |
+
"utility": row["utility"], "score": round(score, 3), "tags": json.loads(row["tags"]),
|
| 325 |
+
"packet": row["packet"], "content_hash": row["content_hash"],
|
| 326 |
+
})
|
| 327 |
+
rows.sort(key=lambda r: r["score"], reverse=True)
|
| 328 |
+
receipt = ledger("recall", req.query[:200], {"query": req.query, "returned": [r["id"] for r in rows[:req.limit]]})
|
| 329 |
+
return {"ok": True, "query": req.query, "results": rows[:req.limit], **receipt}
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
@app.post("/terminal/run", operation_id="runConstrainedTerminal")
|
| 333 |
+
def terminal_run(req: TerminalRequest) -> Dict[str, Any]:
|
| 334 |
+
validate_command(req.command)
|
| 335 |
+
cwd = ensure_relative_path(req.cwd)
|
| 336 |
+
cwd.mkdir(parents=True, exist_ok=True)
|
| 337 |
+
start = time.time()
|
| 338 |
+
env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(WORKSPACE), "LANG": "C.UTF-8"}
|
| 339 |
+
try:
|
| 340 |
+
proc = subprocess.run(
|
| 341 |
+
["/bin/bash", "-lc", req.command],
|
| 342 |
+
cwd=str(cwd), env=env, text=True, capture_output=True,
|
| 343 |
+
timeout=min(req.timeout_seconds, MAX_TIMEOUT),
|
| 344 |
+
)
|
| 345 |
+
elapsed_ms = int((time.time() - start) * 1000)
|
| 346 |
+
result = {
|
| 347 |
+
"ok": proc.returncode == 0,
|
| 348 |
+
"returncode": proc.returncode,
|
| 349 |
+
"stdout": clamp_output(proc.stdout),
|
| 350 |
+
"stderr": clamp_output(proc.stderr),
|
| 351 |
+
"elapsed_ms": elapsed_ms,
|
| 352 |
+
"cwd": str(cwd.relative_to(WORKSPACE)),
|
| 353 |
+
"policy": "constrained-no-key-terminal-v2",
|
| 354 |
+
}
|
| 355 |
+
except subprocess.TimeoutExpired as e:
|
| 356 |
+
result = {"ok": False, "returncode": 124, "stdout": clamp_output(e.stdout or ""), "stderr": "timeout", "elapsed_ms": int((time.time() - start) * 1000), "policy": "constrained-no-key-terminal-v2"}
|
| 357 |
+
receipt = ledger("terminal", req.command[:200], {"request": req.model_dump(), "result": {k: result[k] for k in result if k not in {"stdout", "stderr"}}})
|
| 358 |
+
return {**result, **receipt}
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
@app.post("/python/run", operation_id="runConstrainedPython")
|
| 362 |
+
def python_run(req: PythonRunRequest) -> Dict[str, Any]:
|
| 363 |
+
validate_python(req.code)
|
| 364 |
+
start = time.time()
|
| 365 |
+
with tempfile.NamedTemporaryFile("w", suffix=".py", dir=str(WORKSPACE), delete=False) as f:
|
| 366 |
+
f.write(req.code)
|
| 367 |
+
script = Path(f.name)
|
| 368 |
+
env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(WORKSPACE), "PYTHONNOUSERSITE": "1", "LANG": "C.UTF-8"}
|
| 369 |
+
try:
|
| 370 |
+
proc = subprocess.run(
|
| 371 |
+
["python", "-I", str(script)], cwd=str(WORKSPACE), env=env, text=True, capture_output=True,
|
| 372 |
+
timeout=min(req.timeout_seconds, MAX_TIMEOUT),
|
| 373 |
+
)
|
| 374 |
+
result = {
|
| 375 |
+
"ok": proc.returncode == 0,
|
| 376 |
+
"returncode": proc.returncode,
|
| 377 |
+
"stdout": clamp_output(proc.stdout),
|
| 378 |
+
"stderr": clamp_output(proc.stderr),
|
| 379 |
+
"elapsed_ms": int((time.time() - start) * 1000),
|
| 380 |
+
"script_path": str(script.relative_to(WORKSPACE)),
|
| 381 |
+
"policy": "constrained-python-v2",
|
| 382 |
+
}
|
| 383 |
+
except subprocess.TimeoutExpired as e:
|
| 384 |
+
result = {"ok": False, "returncode": 124, "stdout": clamp_output(e.stdout or ""), "stderr": "timeout", "elapsed_ms": int((time.time() - start) * 1000), "script_path": str(script.relative_to(WORKSPACE)), "policy": "constrained-python-v2"}
|
| 385 |
+
receipt = ledger("python", "python experiment", {"code_hash": sha256_text(req.code), "result": {k: result[k] for k in result if k not in {"stdout", "stderr"}}})
|
| 386 |
+
return {**result, **receipt}
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
@app.post("/files/write", operation_id="writeWorkspaceFile")
|
| 390 |
+
def write_file(req: WriteFileRequest) -> Dict[str, Any]:
|
| 391 |
+
path = ensure_relative_path(req.path)
|
| 392 |
+
if path.exists() and not req.overwrite:
|
| 393 |
+
raise HTTPException(status_code=409, detail="File exists and overwrite=false.")
|
| 394 |
+
data = req.content.encode("utf-8")
|
| 395 |
+
if len(data) > MAX_FILE_BYTES:
|
| 396 |
+
raise HTTPException(status_code=413, detail=f"File exceeds {MAX_FILE_BYTES} bytes.")
|
| 397 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 398 |
+
path.write_text(req.content, encoding="utf-8")
|
| 399 |
+
receipt = ledger("file_write", req.path, {"path": req.path, "bytes": len(data), "sha256": sha256_text(req.content)})
|
| 400 |
+
return {"ok": True, "path": req.path, "bytes": len(data), "sha256": sha256_text(req.content), **receipt}
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
@app.post("/files/read", operation_id="readWorkspaceFile")
|
| 404 |
+
def read_file(req: ReadFileRequest) -> Dict[str, Any]:
|
| 405 |
+
path = ensure_relative_path(req.path)
|
| 406 |
+
if not path.exists() or not path.is_file():
|
| 407 |
+
raise HTTPException(status_code=404, detail="File not found.")
|
| 408 |
+
raw = path.read_bytes()[: req.max_bytes]
|
| 409 |
+
text = raw.decode("utf-8", errors="replace")
|
| 410 |
+
receipt = ledger("file_read", req.path, {"path": req.path, "bytes_returned": len(raw)})
|
| 411 |
+
return {"ok": True, "path": req.path, "content": text, "bytes_returned": len(raw), "truncated": path.stat().st_size > len(raw), **receipt}
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
@app.post("/files/list", operation_id="listWorkspaceFiles")
|
| 415 |
+
def list_files(req: ListFilesRequest) -> Dict[str, Any]:
|
| 416 |
+
base = ensure_relative_path(req.path)
|
| 417 |
+
if not base.exists():
|
| 418 |
+
raise HTTPException(status_code=404, detail="Path not found.")
|
| 419 |
+
items = []
|
| 420 |
+
if base.is_file():
|
| 421 |
+
targets = [base]
|
| 422 |
+
else:
|
| 423 |
+
targets = list(base.rglob("*"))[: req.max_results]
|
| 424 |
+
for p in targets:
|
| 425 |
+
try:
|
| 426 |
+
st = p.stat()
|
| 427 |
+
items.append({"path": str(p.relative_to(WORKSPACE)), "type": "dir" if p.is_dir() else "file", "bytes": st.st_size, "mtime": st.st_mtime})
|
| 428 |
+
except OSError:
|
| 429 |
+
pass
|
| 430 |
+
return {"ok": True, "root": req.path, "items": items, "count": len(items)}
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
@app.post("/artifact/compile", operation_id="compileArtifact")
|
| 434 |
+
def compile_artifact(req: ArtifactRequest) -> Dict[str, Any]:
|
| 435 |
+
slug = re.sub(r"[^a-zA-Z0-9_.-]+", "-", req.title.lower()).strip("-")[:80] or "artifact"
|
| 436 |
+
ext = "md" if req.artifact_type.lower() not in {"json", "csv", "txt"} else req.artifact_type.lower()
|
| 437 |
+
rel = f"artifacts/{int(time.time())}-{slug}.{ext}"
|
| 438 |
+
path = ensure_relative_path(rel)
|
| 439 |
+
header = {
|
| 440 |
+
"title": req.title,
|
| 441 |
+
"artifact_type": req.artifact_type,
|
| 442 |
+
"created_at": now_iso(),
|
| 443 |
+
"metadata": req.metadata,
|
| 444 |
+
}
|
| 445 |
+
content = "---\n" + json.dumps(header, indent=2, ensure_ascii=False) + "\n---\n\n" + req.body
|
| 446 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 447 |
+
path.write_text(content, encoding="utf-8")
|
| 448 |
+
receipt = ledger("artifact", req.title, {"path": rel, "artifact_type": req.artifact_type, "metadata": req.metadata, "sha256": sha256_text(content)})
|
| 449 |
+
learn_req = LearnRequest(
|
| 450 |
+
title=f"Artifact compiled: {req.title}",
|
| 451 |
+
source_kind="artifact",
|
| 452 |
+
observation=f"Compiled {req.artifact_type} artifact at {rel}.",
|
| 453 |
+
reusable_rule=f"For related future work, retrieve and reuse artifact {rel} before answering.",
|
| 454 |
+
evidence={"path": rel, "metadata": req.metadata, "receipt": receipt},
|
| 455 |
+
tags=["artifact", req.artifact_type],
|
| 456 |
+
utility=0.8,
|
| 457 |
+
)
|
| 458 |
+
memory = learn(learn_req)
|
| 459 |
+
return {"ok": True, "path": rel, "sha256": sha256_text(content), "memory_packet_id": memory["packet_id"], **receipt}
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
@app.get("/ledger/recent", operation_id="getRecentLedger")
|
| 463 |
+
def recent_ledger(limit: int = 25) -> Dict[str, Any]:
|
| 464 |
+
limit = max(1, min(limit, 100))
|
| 465 |
+
with sqlite3.connect(DB_PATH) as con:
|
| 466 |
+
con.row_factory = sqlite3.Row
|
| 467 |
+
rows = [dict(r) for r in con.execute("SELECT id, ts, kind, summary, content_hash FROM ledger ORDER BY id DESC LIMIT ?", (limit,))]
|
| 468 |
+
return {"ok": True, "items": rows}
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
@app.get("/receipt/{receipt_id}", operation_id="getReceipt")
|
| 472 |
+
def get_receipt(receipt_id: int) -> Dict[str, Any]:
|
| 473 |
+
with sqlite3.connect(DB_PATH) as con:
|
| 474 |
+
con.row_factory = sqlite3.Row
|
| 475 |
+
row = con.execute("SELECT * FROM ledger WHERE id = ?", (receipt_id,)).fetchone()
|
| 476 |
+
if not row:
|
| 477 |
+
raise HTTPException(status_code=404, detail="Receipt not found.")
|
| 478 |
+
return {"ok": True, "receipt": dict(row)}
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
@app.get("/workspace/tree", response_class=PlainTextResponse, operation_id="getWorkspaceTree")
|
| 482 |
+
def workspace_tree() -> str:
|
| 483 |
+
lines = ["/data/workspace"]
|
| 484 |
+
for p in sorted(WORKSPACE.rglob("*"))[:500]:
|
| 485 |
+
depth = len(p.relative_to(WORKSPACE).parts)
|
| 486 |
+
prefix = " " * depth
|
| 487 |
+
suffix = "/" if p.is_dir() else ""
|
| 488 |
+
lines.append(f"{prefix}{p.name}{suffix}")
|
| 489 |
+
return "\n".join(lines)
|