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
23 changes: 13 additions & 10 deletions .github/workflows/staging-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
id: check
env:
FORCE_RUN: ${{ inputs.force }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
CURRENT_HEAD=$(git rev-parse HEAD)
echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
Expand All @@ -65,8 +66,8 @@ jobs:
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}"
else
git fetch origin main
MERGE_BASE=$(git merge-base origin/main HEAD)
git fetch origin "${DEFAULT_BRANCH}"
MERGE_BASE=$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD)
echo "First run -- reviewing from merge-base ${MERGE_BASE}"
DIFF_RANGE="${MERGE_BASE}..${CURRENT_HEAD}"
fi
Expand Down Expand Up @@ -129,18 +130,19 @@ jobs:
echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
fi

- name: Check if staging is ahead of main
- name: Check if staging is ahead of target branch
id: ahead-check
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
git fetch origin main
AHEAD=$(git rev-list --count origin/main..origin/staging)
git fetch origin "${DEFAULT_BRANCH}"
AHEAD=$(git rev-list --count "origin/${DEFAULT_BRANCH}..origin/staging")
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
if [ "$AHEAD" -eq 0 ]; then
echo "Staging is not ahead of main. Nothing to promote."
echo "Staging is not ahead of ${DEFAULT_BRANCH}. Nothing to promote."
else
echo "Staging is ${AHEAD} commits ahead of main."
echo "Staging is ${AHEAD} commits ahead of ${DEFAULT_BRANCH}."
fi

- name: Create promotion branch
Expand All @@ -159,6 +161,7 @@ jobs:
if: steps.ahead-check.outputs.commits_ahead != '0'
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
# Find the newest open promotion PR with a staging-promote/* head branch
LATEST=$(gh pr list --label staging-promotion --state open \
Expand All @@ -168,8 +171,8 @@ jobs:
echo "base=${LATEST}" >> "$GITHUB_OUTPUT"
echo "Chaining onto existing promotion branch: ${LATEST}"
else
echo "base=main" >> "$GITHUB_OUTPUT"
echo "No existing promotion PR — targeting main"
echo "base=${DEFAULT_BRANCH}" >> "$GITHUB_OUTPUT"
echo "No existing promotion PR — targeting ${DEFAULT_BRANCH}"
fi

- name: Create promotion PR
Expand All @@ -186,7 +189,7 @@ jobs:
PR_URL=$(gh pr create \
--base "$BASE" \
--head "$BRANCH" \
--title "chore: promote staging to main (${TIMESTAMP})" \
--title "chore: promote staging to ${BASE} (${TIMESTAMP})" \
--body "## Auto-promotion from staging CI

**Batch range:** \`${RANGE}\`
Expand Down
65 changes: 63 additions & 2 deletions src/tools/builtin/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//! Use `memory_write` to persist important facts that should be remembered
//! across sessions.

use std::path::Path;
use std::sync::Arc;

use async_trait::async_trait;
Expand All @@ -26,6 +27,28 @@ use crate::workspace::{Workspace, paths};
const PROTECTED_IDENTITY_FILES: &[&str] =
&[paths::IDENTITY, paths::SOUL, paths::AGENTS, paths::USER];

/// Detect paths that are clearly local filesystem references, not workspace-memory docs.
///
/// Examples:
/// - `/Users/.../file.md` (Unix absolute)
/// - `C:\Users\...` or `D:/work/...` (Windows absolute)
/// - `~/notes.md` (home expansion shorthand)
fn looks_like_filesystem_path(path: &str) -> bool {
if path.is_empty() {
return false;
}

if Path::new(path).is_absolute() || path.starts_with("~/") {
return true;
}

let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
}

/// Tool for searching workspace memory.
///
/// Performs hybrid search (FTS + semantic) across all memory documents.
Expand Down Expand Up @@ -143,7 +166,8 @@ impl Tool for MemoryWriteTool {
be remembered across sessions. Targets: 'memory' for curated long-term facts, \
'daily_log' for timestamped session notes, 'heartbeat' for the periodic \
checklist (HEARTBEAT.md), 'bootstrap' to clear the first-run ritual file, \
or provide a custom path for arbitrary file creation."
or provide a custom workspace path for arbitrary file creation. \
Never pass absolute filesystem paths like '/Users/...' or 'C:\\...'."
}

fn parameters_schema(&self) -> serde_json::Value {
Expand Down Expand Up @@ -183,6 +207,14 @@ impl Tool for MemoryWriteTool {
.and_then(|v| v.as_str())
.unwrap_or("daily_log");

if looks_like_filesystem_path(target) {
return Err(ToolError::InvalidParameters(format!(
"'{}' looks like a local filesystem path. memory_write only works with workspace-memory paths. \
Use write_file for filesystem writes. For opening files in an editor, use shell with: open \"<absolute_path>\".",
target
)));
}

// Bootstrap target: clear BOOTSTRAP.md to mark first-run ritual complete.
// Handled early because it accepts empty content (unlike other targets).
if target == "bootstrap" {
Expand Down Expand Up @@ -332,7 +364,8 @@ impl Tool for MemoryReadTool {
fn description(&self) -> &str {
"Read a file from the workspace memory (database-backed storage). \
Use this to read files shown by memory_tree. NOT for local filesystem files \
(use read_file for those). Works with identity files, heartbeat checklist, \
(use read_file for those). Do not pass absolute paths like '/Users/...' or 'C:\\...'. \
Works with identity files, heartbeat checklist, \
memory, daily logs, or any custom workspace path."
}

Expand All @@ -358,6 +391,14 @@ impl Tool for MemoryReadTool {

let path = require_str(&params, "path")?;

if looks_like_filesystem_path(path) {
return Err(ToolError::InvalidParameters(format!(
"'{}' looks like a local filesystem path. memory_read only works with workspace-memory paths. \
Use read_file for filesystem reads. For opening files in an editor, use shell with: open \"<absolute_path>\".",
path
)));
}

let doc = self
.workspace
.read(path)
Expand All @@ -379,6 +420,26 @@ impl Tool for MemoryReadTool {
}
}

#[cfg(test)]
mod path_routing_tests {
use super::looks_like_filesystem_path;

#[test]
fn detects_filesystem_paths() {
assert!(looks_like_filesystem_path("/Users/nige/file.md"));
assert!(looks_like_filesystem_path("C:\\Users\\nige\\file.md"));
assert!(looks_like_filesystem_path("D:/work/file.md"));
assert!(looks_like_filesystem_path("~/notes.md"));
}

#[test]
fn allows_workspace_memory_paths() {
assert!(!looks_like_filesystem_path("MEMORY.md"));
assert!(!looks_like_filesystem_path("daily/2026-03-11.md"));
assert!(!looks_like_filesystem_path("projects/alpha/notes.md"));
}
}

/// Tool for viewing workspace structure as a tree.
///
/// Returns a hierarchical view of files and directories with configurable depth.
Expand Down
Loading