diff --git a/9p b/9p index f5c0b29..acaaef9 160000 --- a/9p +++ b/9p @@ -1 +1 @@ -Subproject commit f5c0b29522e1cc9e5eafac82fc0022b649bd3685 +Subproject commit acaaef997179b6287b14a10d9df401b08a3be11f diff --git a/core b/core index 9ba68ff..abe8ab7 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 9ba68ff35e2ef099ab2ec9a65cd45ff1b3d2ebd6 +Subproject commit abe8ab76ccc4c66a58752fb43141b0b86bf1cdd6 diff --git a/skills b/skills index defc568..afb0e64 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit defc568841d72f316c640624bd8596d9027400df +Subproject commit afb0e64262300caeb4cf37b00fa1390a637cedc5 diff --git a/tools/file_edit b/tools/file_edit new file mode 100755 index 0000000..a78330e --- /dev/null +++ b/tools/file_edit @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# description: Replace text in a file; must read with file_read first +# Args: [replace_all] + +import sys +import os +import hashlib +import difflib +import tempfile + +def marker_path(file_path): + h = hashlib.sha256(file_path.encode()).hexdigest()[:64] + sid = os.environ.get('OLLIE_SESSION_ID', '_') + return f"/tmp/ollie/{sid}/{h}" + +def unified_diff(path, old, new): + old_lines = old.splitlines(keepends=True) + new_lines = new.splitlines(keepends=True) + return ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path)) + +def find_exact(content, find): + return find if find in content else None + +def find_ws_normalized(content, find): + nf = ' '.join(find.split()) + find_lines = find.split('\n') + n = len(find_lines) + lines = content.split('\n') + for i in range(len(lines) - n + 1): + block = '\n'.join(lines[i:i+n]) + if ' '.join(block.split()) == nf: + return block + return None + +def find_indent_flexible(content, find): + def deindent(text): + ls = text.split('\n') + min_indent = float('inf') + for l in ls: + stripped = l.lstrip(' \t') + if stripped: + min_indent = min(min_indent, len(l) - len(stripped)) + if min_indent == float('inf'): + return text + return '\n'.join(l[min_indent:] if len(l) > min_indent else l.lstrip(' \t') for l in ls) + nf = deindent(find) + find_lines = find.split('\n') + n = len(find_lines) + lines = content.split('\n') + for i in range(len(lines) - n + 1): + block = '\n'.join(lines[i:i+n]) + if deindent(block) == nf: + return block + return None + +def find_trimmed_boundary(content, find): + trimmed = find.strip() + if trimmed == find: + return None + if trimmed in content: + return trimmed + find_lines = find.split('\n') + n = len(find_lines) + lines = content.split('\n') + for i in range(len(lines) - n + 1): + block = '\n'.join(lines[i:i+n]) + if block.strip() == trimmed: + return block + return None + +def robust_replace(content, old_string, new_string, replace_all): + match = (find_exact(content, old_string) or + find_ws_normalized(content, old_string) or + find_indent_flexible(content, old_string) or + find_trimmed_boundary(content, old_string)) + if match is None: + return None, "old_string not found in file" + if replace_all: + return content.replace(match, new_string), None + n = content.count(match) + if n > 1: + return None, f"old_string is not unique ({n} occurrences); use replace_all=true or provide more context" + return content.replace(match, new_string, 1), None + +if len(sys.argv) < 4: + print("error: file_path, old_string, and new_string are required") + sys.exit(1) + +file_path = sys.argv[1] +old_string = sys.argv[2] +new_string = sys.argv[3] +replace_all = len(sys.argv) > 4 and sys.argv[4].lower() in ('true', '1', 'yes') + +if not os.path.isabs(file_path): + print(f"error: path must be absolute, got: {file_path}") + sys.exit(1) +if old_string == new_string: + print("error: old_string and new_string must be different") + sys.exit(1) +if not os.path.exists(file_path): + print(f"error: file not found: {file_path}") + sys.exit(1) + +marker = marker_path(file_path) +if not os.path.exists(marker): + print(f"error: {file_path} must be read first with file_read") + sys.exit(1) + +with open(file_path, 'r', errors='replace') as f: + original = f.read() + +new_content, err = robust_replace(original, old_string, new_string, replace_all) +if err: + print(f"error: {err}") + sys.exit(1) + +tmp = tempfile.NamedTemporaryFile(mode='w', dir=os.path.dirname(file_path) or '.', delete=False) +try: + tmp.write(new_content) + tmp.close() + os.replace(tmp.name, file_path) +except Exception as e: + os.unlink(tmp.name) + print(f"error: {e}") + sys.exit(1) + +os.unlink(marker) + +diff = unified_diff(file_path, original, new_content) +print(diff if diff else "(no changes)", end='' if diff else '\n') diff --git a/tools/file_glob b/tools/file_glob new file mode 100755 index 0000000..9dfb69c --- /dev/null +++ b/tools/file_glob @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# description: Find files by name/path pattern, sorted by modification time (newest first) +# Args: [path] + +pattern="$1" +search_path="${2:-$PWD}" + +[ -z "$pattern" ] && echo "error: pattern is required" && exit 1 + +# collect matches and sort by mtime descending +rg --no-config --files -g "$pattern" "$search_path" 2>/dev/null \ + | xargs -d '\n' stat --printf '%Y\t%n\n' 2>/dev/null \ + | sort -rn \ + | cut -f2- diff --git a/tools/file_grep b/tools/file_grep new file mode 100755 index 0000000..4627781 --- /dev/null +++ b/tools/file_grep @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# description: Search file contents using ripgrep +# Args: [--path=] [--mode=files_with_matches|content|count] [--glob=] [--type=] [-i] [-n] [-A ] [-B ] [-C ] [--multiline] [--head=] [--offset=] + +import sys +import os +import subprocess +import argparse + +parser = argparse.ArgumentParser(prog='file_grep', add_help=False) +parser.add_argument('pattern') +parser.add_argument('--path', default='') +parser.add_argument('--mode', default='files_with_matches', + choices=['files_with_matches', 'content', 'count']) +parser.add_argument('--glob', default='') +parser.add_argument('--type', default='') +parser.add_argument('-i', action='store_true') +parser.add_argument('-n', action='store_true', default=True) +parser.add_argument('-A', type=int, default=None) +parser.add_argument('-B', type=int, default=None) +parser.add_argument('-C', type=int, default=None) +parser.add_argument('--multiline', action='store_true') +parser.add_argument('--head', type=int, default=0) +parser.add_argument('--offset', type=int, default=0) + +args = parser.parse_args() + +rg = 'rg' +search_path = args.path or os.getcwd() + +cmd = ['rg', '--no-config', '--color=never'] + +if args.mode == 'files_with_matches': + cmd.append('-l') +elif args.mode == 'count': + cmd.append('--count') +elif args.mode == 'content': + if args.n: + cmd.append('-n') + +if args.i: + cmd.append('-i') +if args.multiline: + cmd += ['-U', '--multiline-dotall'] +if args.glob: + cmd += ['-g', args.glob] +if args.type: + cmd += ['-t', args.type] +if args.C is not None: + cmd += ['-C', str(args.C)] +if args.A is not None: + cmd += ['-A', str(args.A)] +if args.B is not None: + cmd += ['-B', str(args.B)] + +cmd += ['-e', args.pattern, search_path] + +result = subprocess.run(cmd, capture_output=True, text=True) +if result.returncode == 1: + sys.exit(0) # no matches +if result.returncode > 1: + print(result.stderr.strip() or f"rg exited with {result.returncode}", file=sys.stderr) + sys.exit(1) + +lines = result.stdout.splitlines() + +if args.offset: + lines = lines[args.offset:] +if args.head: + lines = lines[:args.head] + +if args.mode == 'content': + formatted = [] + for line in lines: + if line == '--': + formatted.append(line) + continue + # file:lineno:content or file-lineno-content (context lines) + for sep in (':', '-'): + parts = line.split(sep, 2) + if len(parts) == 3 and parts[1].isdigit(): + formatted.append(f"{parts[0]}:{parts[1]:>5}| {parts[2]}") + break + else: + formatted.append(line) + lines = formatted + +print('\n'.join(lines)) diff --git a/tools/file_read b/tools/file_read new file mode 100755 index 0000000..5c17b1a --- /dev/null +++ b/tools/file_read @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# description: Read a file from the local filesystem with optional offset and line limit +# Args: [offset] [limit] + +file_path="$1" +offset="${2:-1}" +limit="${3:-2000}" +max_line_len=2000 +max_size=$((8 * 1024 * 1024)) + +[ -z "$file_path" ] && echo "error: file_path is required" && exit 1 +[[ "$file_path" != /* ]] && echo "error: path must be absolute, got: $file_path" && exit 1 +[ ! -e "$file_path" ] && echo "error: file not found: $file_path" && exit 1 +[ ! -f "$file_path" ] && echo "error: not a regular file: $file_path" && exit 1 + +size=$(wc -c < "$file_path") +[ "$size" -gt "$max_size" ] && echo "error: file too large ($size bytes, max $max_size)" && exit 1 + +content=$(awk -v off="$offset" -v lim="$limit" -v maxlen="$max_line_len" ' + NR >= off && NR < off+lim { + if (length($0) > maxlen) print substr($0, 1, maxlen) "..." + else print + } + NR >= off+lim { exit } +' "$file_path") + +if [ -z "$content" ]; then + echo "(empty file)" +else + printf '%s\n' "$content" +fi + +# create isread marker +mkdir -p "/tmp/ollie/$OLLIE_SESSION_ID" +marker="/tmp/ollie/$OLLIE_SESSION_ID/$(printf '%s' "$file_path" | sha256sum | cut -c1-64)" +touch "$marker" diff --git a/tools/file_write b/tools/file_write new file mode 100755 index 0000000..678b322 --- /dev/null +++ b/tools/file_write @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# description: Write or overwrite a file; must read with file_read first +# Args: + +import sys +import os +import hashlib +import difflib +import tempfile + +def marker_path(file_path): + h = hashlib.sha256(file_path.encode()).hexdigest()[:64] + sid = os.environ.get('OLLIE_SESSION_ID', '_') + return f"/tmp/ollie/{sid}/{h}" + +def unified_diff(path, old, new): + old_lines = old.splitlines(keepends=True) + new_lines = new.splitlines(keepends=True) + return ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path)) + +if len(sys.argv) < 3: + print("error: file_path and content are required") + sys.exit(1) + +file_path = sys.argv[1] +content = sys.argv[2] + +if not os.path.isabs(file_path): + print(f"error: path must be absolute, got: {file_path}") + sys.exit(1) + +marker = marker_path(file_path) +if not os.path.exists(marker): + print(f"error: {file_path} must be read first with file_read") + sys.exit(1) + +exists = os.path.exists(file_path) +old_content = "" +if exists: + with open(file_path, 'r', errors='replace') as f: + old_content = f.read() + +os.makedirs(os.path.dirname(file_path) or '.', exist_ok=True) +tmp = tempfile.NamedTemporaryFile(mode='w', dir=os.path.dirname(file_path) or '.', delete=False) +try: + tmp.write(content) + tmp.close() + os.replace(tmp.name, file_path) +except Exception as e: + os.unlink(tmp.name) + print(f"error: {e}") + sys.exit(1) + +os.unlink(marker) + +if not exists: + for line in content.splitlines(): + print(f"+{line}") +else: + diff = unified_diff(file_path, old_content, content) + print(diff if diff else "(no changes)", end='' if diff else '\n') diff --git a/tools/reasoning_think b/tools/reasoning_think new file mode 100755 index 0000000..d251141 --- /dev/null +++ b/tools/reasoning_think @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# description: Internal reasoning scratchpad; think through complex problems before acting +# Args: + +[ -z "$*" ] && echo "error: thought is required" && exit 1 +exit 0 diff --git a/tools/task_complete.py b/tools/task_complete similarity index 100% rename from tools/task_complete.py rename to tools/task_complete diff --git a/tools/task_plan.sh b/tools/task_plan similarity index 100% rename from tools/task_plan.sh rename to tools/task_plan