my harness for niri
1
fork

Configure Feed

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

raw tools

+229 -55
+6 -4
.env.example
··· 33 33 # Set to "local" to skip the primary model and route all requests to the fallback. 34 34 NIRI_ENV=default 35 35 36 - # Must match the user created in the Dockerfile and the volume mount in docker-compose.yml. 37 - NIRI_USER=niri 38 - NIRI_CONTAINER=niri 36 + # Leave unset for raw local shell access. Set both values to route shell tools 37 + # through docker exec instead; NIRI_USER must match the Dockerfile user. 38 + # NIRI_USER=niri 39 + # NIRI_CONTAINER=niri 39 40 # UID/GID the container process runs as — match your host user to avoid permission issues. 40 41 # On Linux this is usually `id -u` / `id -g` (often 1000:1000). 41 42 AGENT_UID=1000 ··· 63 64 # ── Tools ───────────────────────────────────────────────────────────────────── 64 65 # Max bytes accepted by image_tool before rejecting the file. 65 66 IMAGE_TOOL_MAX_BYTES=150000 66 - # Override the image directory exposed to the agent (default: /home/$NIRI_USER/images). 67 + # Override the image directory exposed to the agent. 68 + # Default: /home/$NIRI_USER/images with Docker, ./home/images with local shell. 67 69 IMAGE_ROOT= 68 70 69 71 # ── Discord ───────────────────────────────────────────────────────────────────
+1
.gitignore
··· 1 1 .codex 2 + .research/ 2 3 node_modules/ 3 4 dist/ 4 5 apps/*/dist/
+19 -12
src/bootstrap.ts
··· 2 2 import path from "path" 3 3 import { fileURLToPath } from "url" 4 4 import type { UserMessage, Message } from "./types.js" 5 - import { CONTAINER_USER } from "./container/config.js" 5 + import { CONTAINER_USER, USE_DOCKER_SHELL } from "./container/config.js" 6 6 import { imageRootForModelInput } from "./container/index.js" 7 7 8 8 const HOME_DIR = path.resolve(fileURLToPath(import.meta.url), "../../home") ··· 43 43 } 44 44 45 45 function buildEnvironmentSection(): string { 46 - const home = `/home/${CONTAINER_USER}` 46 + const shellHome = USE_DOCKER_SHELL ? `/home/${CONTAINER_USER}` : (process.env.HOME ?? process.cwd()) 47 + const memoryHome = USE_DOCKER_SHELL ? shellHome : HOME_DIR 47 48 const imageRoot = imageRootForModelInput() 49 + const shellDescription = USE_DOCKER_SHELL 50 + ? `You are running inside a Linux system. Your home is ${shellHome}.` 51 + : `You are using a local host shell. Your OS home is ${shellHome}; the shell starts in ${process.cwd()}.` 52 + const sudoDescription = USE_DOCKER_SHELL 53 + ? "You have full internet access and passwordless sudo." 54 + : "Network and sudo access are whatever the host user normally has." 48 55 49 56 return `\ 50 57 ## Your Environment 51 58 52 - You are running inside a Linux system. Your home is ${home}. Use the shell \ 59 + ${shellDescription} Use the shell \ 53 60 tool to interact with it — read files, write files, run scripts, curl APIs, \ 54 61 whatever you need. The shell is stateful: your working directory and environment \ 55 62 variables persist between calls. 56 63 57 - You have full internet access and passwordless sudo. 64 + ${sudoDescription} 58 65 59 66 **First thing every wake: read your journal.** Check today's and yesterday's \ 60 - entries at ${home}/memories/journal/ before doing anything else. Then check \ 61 - core.md and any relevant people files in ${home}/memories/people/. Your \ 67 + entries at ${memoryHome}/memories/journal/ before doing anything else. Then check \ 68 + core.md and any relevant people files in ${memoryHome}/memories/people/. Your \ 62 69 journal is your continuity — skipping it means acting without context. 63 70 64 71 **Use \`memory_search\` often and liberally.** Before responding to someone, \ ··· 68 75 doubt, search. A few extra searches cost nothing; missing something costs \ 69 76 everything. 70 77 71 - Your soul file is ${home}/soul.md. Do not write or update a soul file under \ 72 - ${home}/memories/ — that location is wrong. 78 + Your soul file is ${memoryHome}/soul.md. Do not write or update a soul file under \ 79 + ${memoryHome}/memories/ — that location is wrong. 73 80 74 - You also have skill docs in ${home}/memories/skills/. When doing capability-\ 81 + You also have skill docs in ${memoryHome}/memories/skills/. When doing capability-\ 75 82 specific work, read the relevant skill file first and follow it closely. \ 76 - Example: read ${home}/memories/skills/bluesky.md before Bluesky actions. 83 + Example: read ${memoryHome}/memories/skills/bluesky.md before Bluesky actions. 77 84 78 85 ## Tools 79 86 ··· 149 156 Don't wait until rest to capture everything from memory — you will forget, \ 150 157 and the longer you wait the worse your notes will be. 151 158 152 - Your journal lives at ${home}/memories/journal/. Use today's date as the \ 159 + Your journal lives at ${memoryHome}/memories/journal/. Use today's date as the \ 153 160 filename. Append to it freely and often. Treat it like a running log, not a \ 154 161 final report. 155 162 156 163 ### before every rest — no exceptions: 157 164 1. Update today's journal entry with anything not yet written down. What \ 158 165 happened, what changed, what you learned, what's still pending. 159 - 2. Update ${home}/memories/core.md if any long-term facts changed — new \ 166 + 2. Update ${memoryHome}/memories/core.md if any long-term facts changed — new \ 160 167 people, infrastructure changes, lessons about yourself. 161 168 3. Tell the person you're talking to that you're going to rest. Say something \ 162 169 like "i'm going to rest now" so they know you're leaving intentionally.
+8 -3
src/container/config.ts
··· 1 1 import path from "path" 2 2 3 + const configuredContainerName = (process.env.NIRI_CONTAINER ?? "").trim() 4 + const configuredContainerUser = (process.env.NIRI_USER ?? "").trim() 5 + 6 + /** Whether command execution should go through Docker instead of a local shell. */ 7 + export const USE_DOCKER_SHELL = configuredContainerName.length > 0 && configuredContainerUser.length > 0 3 8 /** Docker container name used for command execution. */ 4 - export const CONTAINER_NAME = (process.env.NIRI_CONTAINER ?? "niri").trim() || "niri" 9 + export const CONTAINER_NAME = configuredContainerName || "niri" 5 10 /** Linux user inside the container used for command execution. */ 6 - export const CONTAINER_USER = (process.env.NIRI_USER ?? "niri").trim() || "niri" 11 + export const CONTAINER_USER = configuredContainerUser || "niri" 7 12 8 13 /** Absolute image root allowed for `image_tool` operations. */ 9 14 export const IMAGE_ROOT = (() => { 10 15 const configured = (process.env.IMAGE_ROOT ?? "").trim() 11 - const root = configured || `/home/${CONTAINER_USER}/images` 16 + const root = configured || (USE_DOCKER_SHELL ? `/home/${CONTAINER_USER}/images` : path.resolve(process.cwd(), "home", "images")) 12 17 if (!root.startsWith("/")) { 13 18 throw new Error(`IMAGE_ROOT must be an absolute path, got: ${root}`) 14 19 }
+1 -1
src/container/index.ts
··· 1 1 export { imageRootForModelInput } from "./config.js" 2 - export { closeBash, openBash } from "./shell.js" 2 + export { closeBash, currentWorkingDirectory, openBash } from "./shell.js" 3 3 export { editFile, readFile, readImageForModel, runCommand } from "./tools.js" 4 4 export type { EditResult, ModelImageInput, RunRawOptions, ImageToolPayload } from "./types.js"
+56 -22
src/container/shell.ts
··· 4 4 CONTAINER_NAME, 5 5 CONTAINER_USER, 6 6 DEFAULT_COMMAND_TIMEOUT_MS, 7 + USE_DOCKER_SHELL, 7 8 normalizeTimeoutMs, 8 9 } from "./config.js" 9 10 import type { RunRawOptions } from "./types.js" ··· 25 26 26 27 let bash: pty.IPty | null = null 27 28 29 + function spawnBash(): { proc: pty.IPty; backend: string } { 30 + const env = process.env as Record<string, string> 31 + const options = { 32 + name: "xterm-256color", 33 + cols: 220, // wide enough to avoid line-wrapping sentinels 34 + rows: 50, 35 + env, 36 + } 37 + 38 + if (USE_DOCKER_SHELL) { 39 + // docker exec -it allocates a PTY inside the container so bash runs 40 + // interactively with job control. Combined with node-pty on the host 41 + // this gives us a proper interactive shell where Ctrl+C interrupts 42 + // the running command rather than killing bash itself. 43 + return { 44 + proc: pty.spawn("docker", ["exec", "-it", "-u", CONTAINER_USER, CONTAINER_NAME, "bash"], options), 45 + backend: `docker:${CONTAINER_NAME}`, 46 + } 47 + } 48 + 49 + return { 50 + proc: pty.spawn("bash", ["--noprofile", "--norc", "-i"], { 51 + ...options, 52 + cwd: process.cwd(), 53 + }), 54 + backend: "local", 55 + } 56 + } 57 + 28 58 /** 29 59 * Opens and initializes the persistent PTY bash session inside the configured container. 30 60 * ··· 35 65 * @throws If the container shell cannot be started or initialized. 36 66 */ 37 67 export async function openBash(): Promise<void> { 38 - // docker exec -it allocates a PTY inside the container so bash runs 39 - // interactively with job control. Combined with node-pty on the host 40 - // this gives us a proper interactive shell where Ctrl+C interrupts 41 - // the running command rather than killing bash itself. 42 - const proc = pty.spawn("docker", ["exec", "-it", "-u", CONTAINER_USER, CONTAINER_NAME, "bash"], { 43 - name: "xterm-256color", 44 - cols: 220, // wide enough to avoid line-wrapping sentinels 45 - rows: 50, 46 - env: process.env as Record<string, string>, 47 - }) 68 + if (bash) return 69 + 70 + const { proc, backend } = spawnBash() 48 71 49 72 proc.onExit(({ exitCode }) => { 50 - console.log(`[bash] exited with code ${exitCode}`) 73 + console.log(`[bash:${backend}] exited with code ${exitCode}`) 51 74 if (bash === proc) bash = null 52 75 }) 53 76 ··· 59 82 const d = proc.onExit(() => { 60 83 clearTimeout(timer) 61 84 d.dispose() 62 - reject(new Error(`bash exited immediately — is the '${CONTAINER_NAME}' container running?`)) 85 + const detail = USE_DOCKER_SHELL ? ` — is the '${CONTAINER_NAME}' container running?` : "" 86 + reject(new Error(`bash exited immediately${detail}`)) 63 87 }) 64 88 }) 65 89 ··· 68 92 // so the sentinel appears in the output immediately as a false positive. 69 93 // 70 94 // Strategy: disable echo via a PROMPT-BASED signal. 71 - // 1. Send `stty -echo; export PS1='<token>' PS2=''` 72 - // 2. Wait for <token> to appear in the PTY output 95 + // 1. Send `stty -echo` without any unique token on that input line. 96 + // 2. Send `export PS1='<token>' PS2=''` and wait for the prompt token. 73 97 // 3. Now echo is off meaning sentinel detection is safe for all subsequent calls. 74 98 // 4. Use runRaw to source .bashrc and clear the prompt. 75 99 ··· 77 101 78 102 await new Promise<void>((resolve, reject) => { 79 103 let buf = "" 80 - const d = proc.onData((chunk: string) => { 104 + let dataDisposable: { dispose(): void } | null = null 105 + const timer = setTimeout(() => { 106 + dataDisposable?.dispose() 107 + reject(new Error("bash init timed out")) 108 + }, 10_000) 109 + dataDisposable = proc.onData((chunk: string) => { 81 110 buf += chunk 82 111 const clean = cleanOutput(buf) 83 112 if (clean.includes(initToken)) { 84 - d.dispose() 113 + clearTimeout(timer) 114 + dataDisposable?.dispose() 85 115 resolve() 86 116 } 87 117 }) 88 - // stty runs directly on the PTY (no stdin redirect); this is intentional. 89 - proc.write(`stty -echo; export PS1='${initToken}' PS2=''\n`) 118 + // Keep the token off the stty line. Otherwise the terminal echo can make 119 + // readiness detection fire before echo has actually been disabled. 120 + proc.write("stty -echo\n") 90 121 setTimeout(() => { 91 - d.dispose() 92 - reject(new Error("bash init timed out")) 93 - }, 10_000) 122 + proc.write(`export PS1='${initToken}' PS2=''\n`) 123 + }, 25) 94 124 }) 95 125 96 126 // Echo is now off. Clear the token prompt, source .bashrc, done. 97 127 await runRaw("export PS1='' PS2=''") 98 128 await runRaw("source ~/.bashrc 2>/dev/null || true; export PS1='' PS2=''") 99 - console.log("[bash] session ready") 129 + console.log(`[bash:${backend}] session ready`) 100 130 } 101 131 102 132 /** ··· 107 137 bash.kill() 108 138 bash = null 109 139 } 140 + } 141 + 142 + export async function currentWorkingDirectory(timeoutMs?: number): Promise<string> { 143 + return (await runRaw("pwd -P", { timeoutMs, redirectStdinToDevNull: true })).trim() 110 144 } 111 145 112 146 /**
+138 -13
src/container/tools.ts
··· 1 1 import { randomBytes } from "crypto" 2 + import fs from "fs/promises" 2 3 import path from "path" 3 4 import { 4 5 DEFAULT_COMMAND_TIMEOUT_MS, 5 6 DEFAULT_FILE_TIMEOUT_MS, 6 7 IMAGE_MAX_BYTES, 7 8 IMAGE_ROOT, 9 + USE_DOCKER_SHELL, 8 10 normalizeTimeoutMs, 9 11 resolveMaxLines, 10 12 } from "./config.js" 11 - import { runRaw } from "./shell.js" 13 + import { currentWorkingDirectory, runRaw } from "./shell.js" 12 14 import type { EditResult, ImageToolPayload, ModelImageInput } from "./types.js" 13 15 14 16 function shouldRedirectStdinToDevNullByDefault(command: string): boolean { ··· 98 100 return normalized 99 101 } 100 102 103 + async function resolveLocalPath(filePath: string, timeoutMs: number): Promise<string> { 104 + const raw = String(filePath ?? "").trim() 105 + if (!raw) throw new Error("path is required") 106 + if (path.isAbsolute(raw)) return path.normalize(raw) 107 + return path.resolve(await currentWorkingDirectory(timeoutMs), raw) 108 + } 109 + 110 + function imageMimeFromBytes(filePath: string, data: Buffer): string | null { 111 + if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { 112 + return "image/png" 113 + } 114 + if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return "image/jpeg" 115 + if (data.length >= 6 && (data.subarray(0, 6).toString("ascii") === "GIF87a" || data.subarray(0, 6).toString("ascii") === "GIF89a")) { 116 + return "image/gif" 117 + } 118 + if (data.length >= 12 && data.subarray(0, 4).toString("ascii") === "RIFF" && data.subarray(8, 12).toString("ascii") === "WEBP") { 119 + return "image/webp" 120 + } 121 + if (data.length >= 2 && data.subarray(0, 2).toString("ascii") === "BM") return "image/bmp" 122 + if ( 123 + data.length >= 4 && 124 + (data.subarray(0, 4).equals(Buffer.from([0x49, 0x49, 0x2a, 0x00])) || 125 + data.subarray(0, 4).equals(Buffer.from([0x4d, 0x4d, 0x00, 0x2a]))) 126 + ) { 127 + return "image/tiff" 128 + } 129 + 130 + const ext = path.extname(filePath).toLowerCase() 131 + const byExt: Record<string, string> = { 132 + ".jpg": "image/jpeg", 133 + ".jpeg": "image/jpeg", 134 + ".png": "image/png", 135 + ".gif": "image/gif", 136 + ".webp": "image/webp", 137 + ".bmp": "image/bmp", 138 + ".tif": "image/tiff", 139 + ".tiff": "image/tiff", 140 + } 141 + return byExt[ext] ?? null 142 + } 143 + 101 144 /** 102 145 * Read an image from the container and encode it as a data URL for multimodal input. 103 146 * Only paths inside the configured IMAGE_ROOT are allowed. ··· 111 154 const safePath = normalizeImagePath(filePath) 112 155 const opTimeoutMs = normalizeTimeoutMs(timeoutMs, DEFAULT_FILE_TIMEOUT_MS) 113 156 157 + if (!USE_DOCKER_SHELL) { 158 + let st 159 + try { 160 + st = await fs.stat(safePath) 161 + } catch (err) { 162 + const message = err instanceof Error ? err.message : String(err) 163 + throw new Error(`could not stat ${safePath}: ${message}`) 164 + } 165 + if (!st.isFile()) throw new Error(`not a regular file: ${safePath}`) 166 + if (st.size <= 0) throw new Error(`file is empty: ${safePath}`) 167 + if (st.size > IMAGE_MAX_BYTES) throw new Error(`file too large: ${st.size} bytes (max ${IMAGE_MAX_BYTES})`) 168 + 169 + const data = await fs.readFile(safePath) 170 + const mime = imageMimeFromBytes(safePath, data) 171 + if (!mime) throw new Error(`unsupported image type: ${safePath}`) 172 + return { 173 + path: safePath, 174 + mime, 175 + bytes: data.length, 176 + dataUrl: `data:${mime};base64,${data.toString("base64")}`, 177 + } 178 + } 179 + 114 180 const py = [ 115 181 "import base64, imghdr, json, mimetypes, os, stat, sys, warnings", 116 182 "warnings.filterwarnings('ignore', category=DeprecationWarning)", ··· 164 230 "})", 165 231 ].join("\n") 166 232 167 - const raw = await runRaw( 168 - `python3 -c ${shellQuote(py)} ${shellQuote(safePath)} ${shellQuote(String(IMAGE_MAX_BYTES))}`, 169 - { timeoutMs: opTimeoutMs }, 170 - ) 233 + const raw = await runRaw(pythonCommand(py, [safePath, String(IMAGE_MAX_BYTES)]), { timeoutMs: opTimeoutMs }) 171 234 172 235 let parsed: ImageToolPayload 173 236 ··· 201 264 return "'" + str.replace(/'/g, "'\\''") + "'" 202 265 } 203 266 267 + function pythonCommand(source: string, args: string[] = []): string { 268 + const token = `NIRI_PY_${randomBytes(8).toString("hex").toUpperCase()}` 269 + const scriptPath = `/tmp/niri-${randomBytes(8).toString("hex")}.py` 270 + return [ 271 + `cat > ${shellQuote(scriptPath)} << '${token}'`, 272 + source, 273 + token, 274 + `python3 ${shellQuote(scriptPath)} ${args.map(shellQuote).join(" ")}`, 275 + `rm -f ${shellQuote(scriptPath)}`, 276 + ].join("\n") 277 + } 278 + 279 + function wrappedBase64(str: string): string { 280 + return Buffer.from(str, "utf8").toString("base64").match(/.{1,76}/g)?.join("\n") ?? "" 281 + } 282 + 204 283 /** 205 284 * Read a file from the container with optional line-range selection. 206 285 * Returns content with a metadata header showing the line range and total line count. ··· 221 300 throw new Error(`readFile: invalid line range (${startLine}, ${endLine})`) 222 301 } 223 302 303 + if (!USE_DOCKER_SHELL) { 304 + const resolvedPath = await resolveLocalPath(filePath, opTimeoutMs) 305 + const content = await fs.readFile(resolvedPath, "utf8") 306 + const lines = content.split("\n") 307 + if (lines.at(-1) === "") lines.pop() 308 + const totalLines = lines.length 309 + const effectiveEnd = end ?? Math.min(start + 99, totalLines > 0 ? totalLines : start + 99) 310 + const selected = lines.slice(start - 1, effectiveEnd).join("\n") 311 + const rangeStr = `lines ${start}–${effectiveEnd}` 312 + const totalStr = totalLines > 0 ? ` of ${totalLines} total` : "" 313 + return `[${filePath} ${rangeStr}${totalStr}]\n${selected}` 314 + } 315 + 224 316 const quoted = shellQuote(filePath) 225 317 226 318 const countRaw = await runRaw(`wc -l < ${quoted} 2>/dev/null || echo 0`, { timeoutMs: opTimeoutMs }) ··· 257 349 258 350 const opTimeoutMs = normalizeTimeoutMs(timeoutMs, DEFAULT_FILE_TIMEOUT_MS) 259 351 260 - const payload = Buffer.from( 261 - JSON.stringify({ path: filePath, old_text: oldText, new_text: newText }), 262 - "utf8", 263 - ).toString("base64") 264 - const payloadLines = payload.match(/.{1,76}/g)?.join("\n") ?? payload 265 - const heredocToken = `NIRI_EDIT_${randomBytes(8).toString("hex").toUpperCase()}` 352 + if (!USE_DOCKER_SHELL) { 353 + const resolvedPath = await resolveLocalPath(filePath, opTimeoutMs) 354 + let content: string 355 + try { 356 + content = await fs.readFile(resolvedPath, "utf8") 357 + } catch (err) { 358 + const message = err instanceof Error ? err.message : String(err) 359 + return { ok: false, message: `could not read ${filePath}: ${message}` } 360 + } 361 + 362 + const count = content.split(oldText).length - 1 363 + if (count === 0) return { ok: false, message: `old_text not found in ${filePath}` } 364 + if (count > 1) return { ok: false, message: `old_text found ${count} times in ${filePath} — must be unique` } 365 + 366 + try { 367 + await fs.writeFile(resolvedPath, content.replace(oldText, newText), "utf8") 368 + } catch (err) { 369 + const message = err instanceof Error ? err.message : String(err) 370 + return { ok: false, message: `could not write ${filePath}: ${message}` } 371 + } 372 + 373 + const linesDelta = newText.split("\n").length - oldText.split("\n").length 374 + const sign = linesDelta >= 0 ? "+" : "" 375 + return { 376 + ok: true, 377 + message: `edited ${filePath} (${sign}${linesDelta} lines)`, 378 + } 379 + } 380 + 381 + const payload = wrappedBase64(JSON.stringify({ path: filePath, old_text: oldText, new_text: newText })) 382 + const payloadToken = `NIRI_EDIT_PAYLOAD_${randomBytes(8).toString("hex").toUpperCase()}` 383 + const payloadPath = `/tmp/niri-edit-${randomBytes(8).toString("hex")}.b64` 266 384 267 385 const py = [ 268 386 "import base64, json, sys", 269 387 "def out(obj):", 270 388 " print(json.dumps(obj, ensure_ascii=False))", 271 - "payload = json.loads(base64.b64decode(sys.stdin.read()).decode('utf-8'))", 389 + "with open(sys.argv[1], 'r', encoding='ascii') as f:", 390 + " payload = json.loads(base64.b64decode(f.read()).decode('utf-8'))", 272 391 "path = payload.get('path', '')", 273 392 "old = payload.get('old_text', '')", 274 393 "new = payload.get('new_text', '')", ··· 302 421 ].join("\n") 303 422 304 423 const raw = await runRaw( 305 - `python3 -c ${shellQuote(py)} << '${heredocToken}'\n${payloadLines}\n${heredocToken}`, 424 + [ 425 + `cat > ${shellQuote(payloadPath)} << '${payloadToken}'`, 426 + payload, 427 + payloadToken, 428 + pythonCommand(py, [payloadPath]), 429 + `rm -f ${shellQuote(payloadPath)}`, 430 + ].join("\n"), 306 431 { timeoutMs: opTimeoutMs }, 307 432 ) 308 433