josephrw commited on
Commit
a0cbdfe
·
verified ·
1 Parent(s): b655047

Create App.py

Browse files
Files changed (1) hide show
  1. App.py +489 -0
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)