my harness for niri
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 275 lines 10 kB view raw
1import { randomBytes } from "crypto" 2import path from "path" 3import { 4 DEFAULT_COMMAND_TIMEOUT_MS, 5 DEFAULT_FILE_TIMEOUT_MS, 6 IMAGE_MAX_BYTES, 7 IMAGE_ROOT, 8 normalizeTimeoutMs, 9 resolveMaxLines, 10} from "./config.js" 11import { runRaw } from "./shell.js" 12import type { EditResult, ImageToolPayload, ModelImageInput } from "./types.js" 13 14/** 15 * Run a shell command. 16 * Output is capped at `maxLines` lines (default 150, 40 for known-verbose commands). 17 * When truncated, a "[truncated]" header is prepended so niri knows lines were dropped. 18 * Pass `maxLines: 0` to disable truncation entirely. 19 * 20 * @param command - Shell command to execute. 21 * @param maxLines - Optional line cap override (`0` disables truncation). 22 * @param timeoutMs - Optional command timeout in milliseconds. 23 * @returns Cleaned command output, potentially truncated with a header. 24 */ 25export async function runCommand(command: string, maxLines?: number, timeoutMs?: number): Promise<string> { 26 const cap = resolveMaxLines(command, maxLines) 27 const raw = await runRaw(command, { 28 timeoutMs: normalizeTimeoutMs(timeoutMs, DEFAULT_COMMAND_TIMEOUT_MS), 29 }) 30 31 if (cap === 0) return raw 32 33 const lines = raw.split("\n") 34 if (lines.length <= cap) return raw 35 36 const kept = lines.slice(-cap) 37 return `[truncated — showing last ${cap} of ${lines.length} lines]\n${kept.join("\n")}` 38} 39 40function normalizeImagePath(filePath: string): string { 41 const raw = String(filePath ?? "").trim() 42 if (!raw) throw new Error("image path is required") 43 const normalized = path.posix.normalize(raw) 44 if (!(normalized === IMAGE_ROOT || normalized.startsWith(`${IMAGE_ROOT}/`))) { 45 throw new Error(`image path must be inside ${IMAGE_ROOT}`) 46 } 47 return normalized 48} 49 50/** 51 * Read an image from the container and encode it as a data URL for multimodal input. 52 * Only paths inside the configured IMAGE_ROOT are allowed. 53 * 54 * @param filePath - Absolute image path inside the allowed image root. 55 * @param timeoutMs - Optional read timeout in milliseconds. 56 * @returns Parsed image payload suitable for model multimodal input. 57 * @throws If validation, decoding, or path checks fail. 58 */ 59export async function readImageForModel(filePath: string, timeoutMs?: number): Promise<ModelImageInput> { 60 const safePath = normalizeImagePath(filePath) 61 const opTimeoutMs = normalizeTimeoutMs(timeoutMs, DEFAULT_FILE_TIMEOUT_MS) 62 63 const py = [ 64 "import base64, imghdr, json, mimetypes, os, stat, sys, warnings", 65 "warnings.filterwarnings('ignore', category=DeprecationWarning)", 66 "path = sys.argv[1]", 67 "max_bytes = int(sys.argv[2])", 68 "def out(obj):", 69 " print(json.dumps(obj, ensure_ascii=False))", 70 "try:", 71 " st = os.stat(path)", 72 "except FileNotFoundError:", 73 " out({'ok': False, 'message': f'file not found: {path}'})", 74 " raise SystemExit(0)", 75 "except Exception as e:", 76 " out({'ok': False, 'message': f'could not stat {path}: {e}'})", 77 " raise SystemExit(0)", 78 "if not stat.S_ISREG(st.st_mode):", 79 " out({'ok': False, 'message': f'not a regular file: {path}'})", 80 " raise SystemExit(0)", 81 "if st.st_size <= 0:", 82 " out({'ok': False, 'message': f'file is empty: {path}'})", 83 " raise SystemExit(0)", 84 "if st.st_size > max_bytes:", 85 " out({'ok': False, 'message': f'file too large: {st.st_size} bytes (max {max_bytes})'})", 86 " raise SystemExit(0)", 87 "with open(path, 'rb') as f:", 88 " data = f.read()", 89 "kind = imghdr.what(None, data)", 90 "mime_map = {", 91 " 'jpeg': 'image/jpeg',", 92 " 'png': 'image/png',", 93 " 'gif': 'image/gif',", 94 " 'webp': 'image/webp',", 95 " 'bmp': 'image/bmp',", 96 " 'tiff': 'image/tiff',", 97 "}", 98 "mime = mime_map.get(kind)", 99 "if not mime:", 100 " guessed, _ = mimetypes.guess_type(path)", 101 " if guessed and guessed.startswith('image/'):", 102 " mime = guessed", 103 "if not mime:", 104 " out({'ok': False, 'message': f'unsupported image type: {path}'})", 105 " raise SystemExit(0)", 106 "encoded = base64.b64encode(data).decode('ascii')", 107 "out({", 108 " 'ok': True,", 109 " 'path': path,", 110 " 'mime': mime,", 111 " 'bytes': len(data),", 112 " 'data_url': 'data:' + mime + ';base64,' + encoded,", 113 "})", 114 ].join("\n") 115 116 const raw = await runRaw( 117 `python3 -c ${shellQuote(py)} ${shellQuote(safePath)} ${shellQuote(String(IMAGE_MAX_BYTES))}`, 118 { timeoutMs: opTimeoutMs }, 119 ) 120 121 let parsed: ImageToolPayload 122 123 try { 124 parsed = JSON.parse(raw.trim()) as ImageToolPayload 125 } catch { 126 throw new Error(`image_tool failed for ${safePath}: ${raw.trim() || "unknown error"}`) 127 } 128 129 if (!parsed?.ok) { 130 throw new Error(parsed?.message ?? `image_tool failed for ${safePath}`) 131 } 132 133 const bytes = parsed.bytes 134 if (!parsed.path || !parsed.mime || !parsed.data_url || typeof bytes !== "number" || !Number.isFinite(bytes)) { 135 throw new Error(`image_tool returned invalid payload for ${safePath}`) 136 } 137 138 return { 139 path: parsed.path, 140 mime: parsed.mime, 141 bytes, 142 dataUrl: parsed.data_url, 143 } 144} 145 146/** 147 * Escape a string for safe interpolation into a POSIX shell command. 148 */ 149function shellQuote(str: string): string { 150 return "'" + str.replace(/'/g, "'\\''") + "'" 151} 152 153/** 154 * Read a file from the container with optional line-range selection. 155 * Returns content with a metadata header showing the line range and total line count. 156 * Defaults to lines 1–100 when no range is given. 157 * 158 * @param filePath - Path to the file inside the container. 159 * @param startLine - Inclusive 1-based start line. 160 * @param endLine - Optional inclusive 1-based end line. 161 * @param timeoutMs - Optional read timeout in milliseconds. 162 * @returns Header plus selected file content. 163 */ 164export async function readFile(filePath: string, startLine = 1, endLine?: number, timeoutMs?: number): Promise<string> { 165 const start = Math.max(1, Math.trunc(Number(startLine)) || 1) 166 const end = endLine !== undefined ? Math.max(start, Math.trunc(Number(endLine)) || start) : undefined 167 const opTimeoutMs = normalizeTimeoutMs(timeoutMs, DEFAULT_FILE_TIMEOUT_MS) 168 169 if (!Number.isFinite(start) || (end !== undefined && !Number.isFinite(end))) { 170 throw new Error(`readFile: invalid line range (${startLine}, ${endLine})`) 171 } 172 173 const quoted = shellQuote(filePath) 174 175 const countRaw = await runRaw(`wc -l < ${quoted} 2>/dev/null || echo 0`, { timeoutMs: opTimeoutMs }) 176 const totalLines = parseInt(countRaw.trim(), 10) || 0 177 178 const effectiveEnd = end ?? Math.min(start + 99, totalLines > 0 ? totalLines : start + 99) 179 180 const content = await runRaw(`sed -n '${start},${effectiveEnd}p' ${quoted} 2>&1`, { timeoutMs: opTimeoutMs }) 181 182 const rangeStr = `lines ${start}${effectiveEnd}` 183 const totalStr = totalLines > 0 ? ` of ${totalLines} total` : "" 184 const header = `[${filePath} ${rangeStr}${totalStr}]\n` 185 186 return header + content 187} 188 189/** 190 * Edit a file by replacing an exact snippet of text. 191 * old_text must appear exactly once; errors clearly if 0 or >1 matches. 192 * 193 * Uses an in-container python transform so we do not stream huge file bodies 194 * through the PTY (which is fragile on very large files). 195 * 196 * @param filePath - File path to edit. 197 * @param oldText - Exact text to replace (must match exactly once). 198 * @param newText - Replacement text. 199 * @param timeoutMs - Optional operation timeout in milliseconds. 200 * @returns Structured edit result describing success/failure. 201 */ 202export async function editFile(filePath: string, oldText: string, newText: string, timeoutMs?: number): Promise<EditResult> { 203 if (oldText.length === 0) { 204 return { ok: false, message: "old_text must not be empty" } 205 } 206 207 const opTimeoutMs = normalizeTimeoutMs(timeoutMs, DEFAULT_FILE_TIMEOUT_MS) 208 209 const payload = Buffer.from( 210 JSON.stringify({ path: filePath, old_text: oldText, new_text: newText }), 211 "utf8", 212 ).toString("base64") 213 const payloadLines = payload.match(/.{1,76}/g)?.join("\n") ?? payload 214 const heredocToken = `NIRI_EDIT_${randomBytes(8).toString("hex").toUpperCase()}` 215 216 const py = [ 217 "import base64, json, sys", 218 "def out(obj):", 219 " print(json.dumps(obj, ensure_ascii=False))", 220 "payload = json.loads(base64.b64decode(sys.stdin.read()).decode('utf-8'))", 221 "path = payload.get('path', '')", 222 "old = payload.get('old_text', '')", 223 "new = payload.get('new_text', '')", 224 "if old == '':", 225 " out({'ok': False, 'message': 'old_text must not be empty'})", 226 " raise SystemExit(0)", 227 "try:", 228 " with open(path, 'r', encoding='utf-8') as f:", 229 " content = f.read()", 230 "except FileNotFoundError:", 231 " out({'ok': False, 'message': f'could not read {path}: file not found'})", 232 " raise SystemExit(0)", 233 "except Exception as e:", 234 " out({'ok': False, 'message': f'could not read {path}: {e}'})", 235 " raise SystemExit(0)", 236 "count = content.count(old)", 237 "if count == 0:", 238 " out({'ok': False, 'message': f'old_text not found in {path}'})", 239 " raise SystemExit(0)", 240 "if count > 1:", 241 " out({'ok': False, 'message': f'old_text found {count} times in {path} — must be unique'})", 242 " raise SystemExit(0)", 243 "updated = content.replace(old, new, 1)", 244 "try:", 245 " with open(path, 'w', encoding='utf-8') as f:", 246 " f.write(updated)", 247 "except Exception as e:", 248 " out({'ok': False, 'message': f'could not write {path}: {e}'})", 249 " raise SystemExit(0)", 250 "out({'ok': True})", 251 ].join("\n") 252 253 const raw = await runRaw( 254 `python3 -c ${shellQuote(py)} << '${heredocToken}'\n${payloadLines}\n${heredocToken}`, 255 { timeoutMs: opTimeoutMs }, 256 ) 257 258 let parsed: { ok?: boolean; message?: string } | null = null 259 try { 260 parsed = JSON.parse(raw.trim()) as { ok?: boolean; message?: string } 261 } catch { 262 return { ok: false, message: `edit failed for ${filePath}: ${raw.trim() || "unknown error"}` } 263 } 264 265 if (!parsed.ok) { 266 return { ok: false, message: parsed.message ?? `edit failed for ${filePath}` } 267 } 268 269 const linesDelta = newText.split("\n").length - oldText.split("\n").length 270 const sign = linesDelta >= 0 ? "+" : "" 271 return { 272 ok: true, 273 message: `edited ${filePath} (${sign}${linesDelta} lines)`, 274 } 275}