Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Patch's changes are consolidated below as an unreleased baseline, not published

### Fixed

- `search_code` now runs the query through `redactSecrets` before sending it to OpenRouter for embedding and reranking, like the indexed chunks and `web_search` (fixes #286).
- Windows sandbox helper (`native/sandbox-helper`): `Jobs` holds an `Arc<Handle>` for each running job instead of a raw `usize`, `LPPROC_THREAD_ATTRIBUTE_LIST` is freed on early returns as well as after process creation, and mutex locks recover from poison instead of panicking (fixes #299).
- A file the chat read that changed right as a run ended (a formatter or file watcher writing as soon as the agent stops) was not reported to the model as changed outside the chat: its size and time were taken in the background after the run, so the change could become part of the baseline. They are now taken before the run reports it is done (fixes #290).
- The native file helper read a whole file into memory before Patch's size checks ran, so an edit of, or a read raced against, a multi-gigabyte file could exhaust memory in the helper and the app. It now refuses files over 16 MB (to read or write) without reading past the limit, caps its request at 128 MB, and the app stops collecting its output past 24 MB; the sandbox helper caps each request line at 16 MB (fixes #297).
- An edit to a protected top-level entry (`.github`, `.vscode`, `AGENTS.md`) by an absolute path that spells the project root through an alias (`/var` for `/private/var` on macOS, a Windows 8.3 name such as `RUNNER~1`) was not recorded as Patch's own write, so the sandbox guard could move the approved edit aside as if a command had created it. The record now uses the same alias-aware relative path as the write (refs #276).
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ The Windows helper also journals a private per-command root under `%LOCALAPPDATA

## Code search (`src/main/search/`)

`CodeIndex` walks the project (respecting ignore rules, up to _Maximum files to index_), skips credential files (`isSecretPath` in `tools/guard.ts`: `.env`, keys, `.npmrc`, `.ssh` and similar), splits text files into overlapping 60-line chunks, masks secrets in each chunk with `redactSecrets` (the reranker's documents too, #234), embeds them with Voyage `voyage-code-4` through OpenRouter's embeddings endpoint (`openRouterEmbedder`: `input_type` `document` for chunks and `query` for the search text, as Voyage recommends for retrieval; 1024 dimensions, the model's default) and stores Float32 vectors in `userData/indexes/<sha1 of path>.json`. Updates re-embed only files whose size or modification time changed, and each search refreshes the index once, reporting indexing progress to the tool card. A file is stored as soon as all its chunks are embedded, and the index file is saved every five batches and when the update ends, also after a failure or a stop, so a later run embeds only what is left. Concurrent searches share one update: each caller's stop ends only its own wait, and the update stops when every waiting caller has stopped. Cached indexes are dropped only when the OpenRouter key changes (#170); `CodeIndex.dispose()` then stops a running update after its current batch and keeps the dropped index from saving, so the old key is not used any more and the new index file is not overwritten (#248). The index is built on the first `search_code` call, never in the background. Search ranks by cosine similarity, at most two snippets per file. The best 30 of those go to Voyage `rerank-3` through OpenRouter's rerank endpoint (`openRouterReranker`, same key), which picks the final results; if reranking fails (anything but a stop), the results stay in embedding order and the tool output says why. Changing `INDEX_VERSION` or the embedding model rebuilds indexes. Settings shows the current project's index status (`index:status`: files and chunks, or why it is unavailable) and a Reindex button (`index:rebuild`, `CodeIndex.rebuild()`), which clears the index and embeds everything again.
`CodeIndex` walks the project (respecting ignore rules, up to _Maximum files to index_), skips credential files (`isSecretPath` in `tools/guard.ts`: `.env`, keys, `.npmrc`, `.ssh` and similar), splits text files into overlapping 60-line chunks, masks secrets in each chunk, the reranker's documents and the search query itself with `redactSecrets` (#234, #286), embeds them with Voyage `voyage-code-4` through OpenRouter's embeddings endpoint (`openRouterEmbedder`: `input_type` `document` for chunks and `query` for the search text, as Voyage recommends for retrieval; 1024 dimensions, the model's default) and stores Float32 vectors in `userData/indexes/<sha1 of path>.json`. Updates re-embed only files whose size or modification time changed, and each search refreshes the index once, reporting indexing progress to the tool card. A file is stored as soon as all its chunks are embedded, and the index file is saved every five batches and when the update ends, also after a failure or a stop, so a later run embeds only what is left. Concurrent searches share one update: each caller's stop ends only its own wait, and the update stops when every waiting caller has stopped. Cached indexes are dropped only when the OpenRouter key changes (#170); `CodeIndex.dispose()` then stops a running update after its current batch and keeps the dropped index from saving, so the old key is not used any more and the new index file is not overwritten (#248). The index is built on the first `search_code` call, never in the background. Search ranks by cosine similarity, at most two snippets per file. The best 30 of those go to Voyage `rerank-3` through OpenRouter's rerank endpoint (`openRouterReranker`, same key), which picks the final results; if reranking fails (anything but a stop), the results stay in embedding order and the tool output says why. Changing `INDEX_VERSION` or the embedding model rebuilds indexes. Settings shows the current project's index status (`index:status`: files and chunks, or why it is unavailable) and a Reindex button (`index:rebuild`, `CodeIndex.rebuild()`), which clears the index and embeds everything again.

## Chat list (`src/renderer/src/views/sidebar.ts`)

Expand Down
64 changes: 39 additions & 25 deletions native/sandbox-helper/src/file_ops/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ fn write_in_place(file: &mut File, bytes: &[u8]) -> io::Result<()> {
// The new file gets the old one's mode, and its owner where that is allowed. When no file can be created in the folder
// (it is not writable), it is written in place as before.
fn replace(change: &mut Pending, bytes: &[u8]) -> io::Result<()> {
let leaf = change.parts.last().unwrap().clone();
let Some(leaf) = change.parts.last().cloned() else {
return Err(invalid("A file path is required."));
};
let (Some(parent), Some(old)) = (change.parent.as_ref(), change.file.as_mut()) else {
return Err(invalid("Missing the file to replace."));
};
Expand Down Expand Up @@ -160,14 +162,13 @@ impl Root {
}

pub fn read(&self, parts: &[String]) -> io::Result<Option<Vec<u8>>> {
let Some(leaf) = parts.last() else {
return Err(invalid("A file path is required."));
};
let Some(parent) = self.parent(parts, false, &mut Vec::new())? else {
return Ok(None);
};
let mut file = match open_at(
&parent,
parts.last().unwrap(),
libc::O_RDONLY | libc::O_NONBLOCK,
) {
let mut file = match open_at(&parent, leaf, libc::O_RDONLY | libc::O_NONBLOCK) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
Expand All @@ -182,13 +183,12 @@ impl Root {
before: Option<Vec<u8>>,
after: Option<Vec<u8>>,
) -> io::Result<Pending> {
let Some(leaf) = parts.last() else {
return Err(invalid("A file path is required."));
};
let parent = self.parent(&parts, false, &mut Vec::new())?;
let mut file = match &parent {
Some(parent) => match open_at(
parent,
parts.last().unwrap(),
libc::O_RDWR | libc::O_NONBLOCK,
) {
Some(parent) => match open_at(parent, leaf, libc::O_RDWR | libc::O_NONBLOCK) {
Ok(file) => Some(file),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
Expand Down Expand Up @@ -218,20 +218,28 @@ impl Root {
}

pub fn apply(&self, change: &mut Pending) -> io::Result<()> {
let leaf = change.parts.last().unwrap();
let Some(leaf) = change.parts.last().cloned() else {
return Err(invalid("A file path is required."));
};
if let Some(bytes) = &change.after {
if change.file.is_none() {
if change.parent.is_none() {
change.parent = self.parent(&change.parts, true, &mut change.created_dirs)?;
let file = match change.file.as_mut() {
Some(file) => file,
None => {
let parent = match change.parent.as_ref() {
Some(parent) => parent,
None => {
let parent = self
.parent(&change.parts, true, &mut change.created_dirs)?
.ok_or_else(|| invalid("Missing parent folder."))?;
change.parent.insert(parent)
}
};
let file =
open_at(parent, &leaf, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
change.created_file = true;
change.file.insert(file)
}
change.file = Some(open_at(
change.parent.as_ref().unwrap(),
leaf,
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
)?);
change.created_file = true;
}
let file = change.file.as_mut().unwrap();
};
regular(file, true)?;
change.written = true;
if change.created_file {
Expand All @@ -242,7 +250,11 @@ impl Root {
replace(change, &bytes)?;
}
} else if change.file.is_some() {
unlink_at(change.parent.as_ref().unwrap(), leaf, 0)?;
let parent = change
.parent
.as_ref()
.ok_or_else(|| invalid("Missing parent folder."))?;
unlink_at(parent, &leaf, 0)?;
change.deleted = true;
}
Ok(())
Expand All @@ -252,7 +264,9 @@ impl Root {
// bytes, a created file and the folders created for it are removed, and a deleted file is written again with its
// old bytes and mode.
pub fn undo(&self, change: &mut Pending) -> io::Result<()> {
let leaf = change.parts.last().unwrap().clone();
let Some(leaf) = change.parts.last().cloned() else {
return Err(invalid("A file path is required."));
};
if change.deleted {
let (Some(parent), Some(before)) = (change.parent.as_ref(), &change.before) else {
return Err(invalid("Missing the original content to restore."));
Expand Down
37 changes: 20 additions & 17 deletions native/sandbox-helper/src/file_ops/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ pub struct Pending {

// Marks the file behind a handle for deletion when its last handle closes, or takes the mark back.
fn set_delete_on_close(file: &File, delete: bool) -> io::Result<()> {
let disposition = FILE_DISPOSITION_INFO {
DeleteFile: delete.into(),
};
let disposition = FILE_DISPOSITION_INFO { DeleteFile: delete };
unsafe {
SetFileInformationByHandle(
HANDLE(file.as_raw_handle()),
Expand Down Expand Up @@ -353,9 +351,12 @@ impl Root {
create: bool,
created: &mut Vec<PathBuf>,
) -> io::Result<(PathBuf, Vec<File>)> {
let Some((leaf, parents)) = parts.split_last() else {
return Err(invalid("A file path is required."));
};
let mut path = self.path.clone();
let mut handles = Vec::new();
for component in &parts[..parts.len() - 1] {
for component in parents {
path.push(component);
match directory(&path) {
Ok(handle) => handles.push(handle),
Expand All @@ -370,7 +371,7 @@ impl Root {
Err(e) => return Err(e),
}
}
path.push(parts.last().unwrap());
path.push(leaf);
Ok((path, handles))
}

Expand Down Expand Up @@ -458,23 +459,25 @@ impl Root {

pub fn apply(&self, change: &mut Pending) -> io::Result<()> {
if let Some(bytes) = &change.after {
if change.file.is_none() {
// Rewalk (and retain) any previously missing parents before creating the file exclusively.
let (path, parents) = self.parent(&change.parts, true, &mut change.created_dirs)?;
change.path = path;
change._parents = parents;
change.file = Some(
OpenOptions::new()
let file = match change.file.as_mut() {
Some(file) => file,
None => {
// Rewalk (and retain) any previously missing parents before creating the file exclusively.
let (path, parents) =
self.parent(&change.parts, true, &mut change.created_dirs)?;
change.path = path;
change._parents = parents;
let file = OpenOptions::new()
.write(true)
.create_new(true)
.access_mode(CREATED_FILE_ACCESS)
.share_mode(FILE_SHARE_READ.0)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0)
.open(&change.path)?,
);
change.created_file = true;
}
let file = change.file.as_mut().unwrap();
.open(&change.path)?;
change.created_file = true;
change.file.insert(file)
}
};
regular(file, true)?;
change.written = true;
if change.created_file {
Expand Down
2 changes: 1 addition & 1 deletion native/sandbox-helper/src/pipe_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub fn wide(text: &str) -> Vec<u16> {
}

fn io_error(error: windows::core::Error) -> io::Error {
io::Error::from_raw_os_error((error.code().0 & 0xffff) as i32)
io::Error::from_raw_os_error(error.code().0 & 0xffff)
}

// A manual-reset event: once set, every wait that includes it returns at once.
Expand Down
10 changes: 6 additions & 4 deletions native/sandbox-helper/src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ pub fn quote_arg(arg: &str) -> String {
match c {
'\\' => backslashes += 1,
'"' => {
out.extend(std::iter::repeat('\\').take(backslashes * 2 + 1));
out.extend(std::iter::repeat_n('\\', backslashes * 2 + 1));
out.push('"');
backslashes = 0;
}
_ => {
out.extend(std::iter::repeat('\\').take(backslashes));
out.extend(std::iter::repeat_n('\\', backslashes));
out.push(c);
backslashes = 0;
}
}
}
out.extend(std::iter::repeat('\\').take(backslashes * 2));
out.extend(std::iter::repeat_n('\\', backslashes * 2));
out.push('"');
out
}
Expand Down Expand Up @@ -93,7 +93,9 @@ impl Utf8Chunker {
}
Err(e) => {
let valid = e.valid_up_to();
out.push_str(std::str::from_utf8(&self.pending[..valid]).unwrap());
if let Ok(text) = std::str::from_utf8(&self.pending[..valid]) {
out.push_str(text);
}
match e.error_len() {
Some(len) => {
out.push('\u{FFFD}');
Expand Down
Loading
Loading