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
29 changes: 26 additions & 3 deletions docs/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,11 +597,34 @@ pruning sessions whose source transcript disappeared, and backfilling repo
identity for sessions indexed before it was resolvable. Deleting sessions that
match `excluded_paths` still runs, restricted to the current scope.

### Scheduled Sync (macOS)

```bash
recall sync schedule install --interval 30m
recall sync schedule status
recall sync schedule uninstall
```

The interval defaults to 30m; use an integer with `s`, `m`, or `h` units and a
minimum of 60 seconds. The LaunchAgent label is `io.github.samzong.recall.sync`;
its plist is `~/Library/LaunchAgents/io.github.samzong.recall.sync.plist` and
logs are `~/Library/Logs/recall/sync.stdout.log` and
`~/Library/Logs/recall/sync.stderr.log`.

Install runs the first sync immediately. macOS may prompt for access to multiple
folders in sequence; click Allow within 60 seconds for each prompt. If the first
sync fails, rerun `recall sync schedule install` (include `--interval` again to
retain a custom interval).

Git metadata probes time out after 60 seconds and abort the whole sync. The same
timeout applies to manual syncs and read commands that infer a project from the
current directory; read commands can bypass inference with `--project all`.
Linux and Windows report `not supported yet on <os>`.

## Backward Compatibility

- Existing commands keep working: `recall search`, `recall export`,
`recall import`, `recall usage`, `recall share init`, and the TUI remain
unchanged.
- Existing command names and flags remain available: `recall search`,
`recall export`, `recall import`, `recall usage`, `recall share init`, and the TUI.
- Existing `recall export` remains the bulk export command.
- Existing TUI shortcuts keep using the same internal session operations.
- Export record schema is `v7`: event records retain `files` and nullable
Expand Down
8 changes: 8 additions & 0 deletions skills/recall/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ If neither MCP nor the installed CLI is available, stop and offer `brew install
- `all` covers every indexed project.
- The CLI derives an omitted `--project` from its working directory. Recall MCP treats an omitted `project` as global, so pass the current project unless the user explicitly requests all projects.

## Scheduled Sync

- `recall sync schedule install --interval 30m`
- `recall sync schedule status`
- `recall sync schedule uninstall`

Run install or uninstall only when the user explicitly requests a schedule change; run status only when the user asks to inspect it.

## Find Sessions

For a specific historical question, use MCP `search_messages` with keywords and the current project. Each match includes `session_id`, `seq`, `role`, and an excerpt around the match. Add `session_id` to search within a known session. This is full-text matching, not semantic search; use `search_sessions` for broader session discovery and `list_recent_sessions` when there is no query.
Expand Down
13 changes: 12 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ enum Commands {
#[command(subcommand)]
command: session::SessionCommands,
},
#[command(about = "Scan configured AI coding session sources")]
#[command(
about = "Scan configured AI coding session sources",
args_conflicts_with_subcommands = true
)]
Sync {
#[command(subcommand)]
command: Option<crate::schedule::SyncCommands>,
#[arg(
long,
conflicts_with_all = ["backfill_events", "dry_run", "force", "project"],
Expand Down Expand Up @@ -310,6 +315,12 @@ pub(crate) fn run() -> Result<()> {

match cli.command {
Some(Commands::Info { format }) => crate::info::run(format)?,
Some(Commands::Sync {
command: Some(crate::schedule::SyncCommands::Schedule { command }),
..
}) => {
crate::schedule::run(command)?;
}
Some(Commands::Sync { session: Some(session), source, format, .. }) => {
crate::sync::run_single_session(source.as_deref(), &session, format)?;
}
Expand Down
58 changes: 58 additions & 0 deletions src/cli/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,64 @@ use super::{
use crate::session;
use clap::{CommandFactory, Parser};

#[test]
fn sync_schedule_preserves_manual_sync_and_excludes_mixed_arguments() {
use crate::schedule::{ScheduleCommands, SyncCommands};

let cli = Cli::try_parse_from(["recall", "sync", "schedule", "install"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Sync {
command: Some(SyncCommands::Schedule {
command: ScheduleCommands::Install { interval: 1800 }
}),
..
})
));
let cli =
Cli::try_parse_from(["recall", "sync", "schedule", "install", "--interval", "2m"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Sync {
command: Some(SyncCommands::Schedule {
command: ScheduleCommands::Install { interval: 120 }
}),
..
})
));
for action in ["status", "uninstall"] {
assert!(Cli::try_parse_from(["recall", "sync", "schedule", action]).is_ok());
}
for args in [
vec!["recall", "sync"],
vec!["recall", "sync", "--project", "all"],
vec!["recall", "sync", "--session", "native-id", "--source", "codex", "--format", "json"],
vec!["recall", "sync", "--source", "codex", "--force", "--verbose"],
vec!["recall", "sync", "--backfill-events", "--dry-run", "--project", "all"],
] {
assert!(matches!(
Cli::try_parse_from(args).unwrap().command,
Some(Commands::Sync { command: None, .. })
));
}
for args in [
vec!["recall", "sync", "--project", "all", "schedule", "install"],
vec![
"recall",
"sync",
"--session",
"native-id",
"--source",
"codex",
"schedule",
"install",
],
vec!["recall", "sync", "schedule", "status", "--force"],
] {
assert!(Cli::try_parse_from(args).is_err());
}
}

#[test]
fn export_accepts_default_jsonl_without_format_flag() {
let cli = Cli::try_parse_from([
Expand Down
7 changes: 4 additions & 3 deletions src/db/project_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,10 @@ impl Store {
scope: self.resolve_project_selector(project)?,
inferred: false,
}),
None => {
Ok(ScopeResolution { scope: crate::project_scope::auto_scope(), inferred: true })
}
None => Ok(ScopeResolution {
scope: crate::project_scope::try_auto_scope()?,
inferred: true,
}),
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/db/search/file_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ impl SearchEngine<'_> {
let directory = directory
.to_str()
.ok_or_else(|| anyhow::anyhow!("invalid target directory"))?;
let root = git_toplevel(directory).unwrap_or_else(|| directory.to_string());
let remote = origin_identity(&root).map(|identity| identity.remote);
let root = git_toplevel(directory)?.unwrap_or_else(|| directory.to_string());
let remote = origin_identity(&root)?.map(|identity| identity.remote);
(remote, Some(root))
}
SelectorForm::Repository(identity) => (Some(identity.remote), None),
Expand Down Expand Up @@ -73,7 +73,7 @@ impl SearchEngine<'_> {
};
let mut absolute_path = None;
let relative = if std::path::Path::new(path).is_absolute() || root.is_some() {
let resolved = RepoIdentityCache::default().resolve_file(path, root.as_deref());
let resolved = RepoIdentityCache::default().resolve_file(path, root.as_deref())?;
if let Some(file) = resolved.filter(|file| {
remote.as_deref().is_some_and(|remote| file.repo_remote.as_deref() == Some(remote))
|| root.as_deref().is_some_and(|root| {
Expand Down
1 change: 1 addition & 0 deletions src/integration/regression/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ fn sync_resolves_cross_repository_files_and_keeps_native_paths() {
if alternate.is_dir() {
let resolved = crate::repo_identity::RepoIdentityCache::default()
.resolve_file("src/deleted.rs", alternate.to_str())
.unwrap()
.unwrap();
assert_eq!(&resolved, file);
}
Expand Down
2 changes: 1 addition & 1 deletion src/integration/roundtrip_conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ fn expected_contract(source: &str, raw: &RawSession) -> Value {
.map(|event| {
let mut files = event.files.clone();
for file in &mut files {
file.target = identities.resolve_file(&file.path, file.cwd.as_deref());
file.target = identities.resolve_file(&file.path, file.cwd.as_deref()).unwrap();
}
json!({
"event_seq": event.event_seq,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub(crate) mod project_scope;
pub(crate) mod query;
pub(crate) mod remote;
pub(crate) mod repo_identity;
pub(crate) mod schedule;
pub(crate) mod semantic;
pub(crate) mod session;
pub(crate) mod session_action;
Expand Down
29 changes: 18 additions & 11 deletions src/project_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,29 +145,36 @@ pub(crate) fn classify_selector(value: &str) -> SelectorForm {
}
}

pub(crate) fn auto_scope_for_dir(dir: &Path) -> ProjectScope {
pub(crate) fn auto_scope_for_dir(dir: &Path) -> anyhow::Result<ProjectScope> {
let Some(dir) = dir.to_str() else {
return ProjectScope::Global;
return Ok(ProjectScope::Global);
};
let Some(toplevel) = git_toplevel(dir) else {
return ProjectScope::Global;
let Some(toplevel) = git_toplevel(dir)? else {
return Ok(ProjectScope::Global);
};
match origin_identity(&toplevel) {
Ok(match origin_identity(&toplevel)? {
Some(identity) => ProjectScope::Repository {
filter: RepoFilter::Remote(identity.remote),
local_root: Some(toplevel),
},
None => ProjectScope::Directory(toplevel),
}
})
}

pub(crate) fn auto_scope() -> ProjectScope {
pub(crate) fn try_auto_scope() -> anyhow::Result<ProjectScope> {
match std::env::current_dir() {
Ok(dir) => auto_scope_for_dir(&dir),
Err(_) => ProjectScope::Global,
Err(_) => Ok(ProjectScope::Global),
}
}

pub(crate) fn auto_scope() -> ProjectScope {
try_auto_scope().unwrap_or_else(|error| {
eprintln!("Cannot infer project scope: {error}");
ProjectScope::Global
})
}

fn looks_like_path(value: &str) -> bool {
value.starts_with('/') || value.starts_with('.') || value.starts_with('~')
}
Expand Down Expand Up @@ -235,7 +242,7 @@ mod tests {
std::fs::create_dir_all(&nested).unwrap();
git_init(&root, Some("git@github.com:samzong/Recall.git"));

let scope = auto_scope_for_dir(&nested);
let scope = auto_scope_for_dir(&nested).unwrap();

let root_name = root.file_name().unwrap().to_str().unwrap().to_string();
match scope {
Expand All @@ -254,7 +261,7 @@ mod tests {
let root = temp_dir("recall-scope-noorigin");
git_init(&root, None);

let scope = auto_scope_for_dir(&root);
let scope = auto_scope_for_dir(&root).unwrap();

match scope {
ProjectScope::Directory(directory) => {
Expand All @@ -270,7 +277,7 @@ mod tests {
fn auto_scope_is_global_outside_git() {
let root = temp_dir("recall-scope-nongit");

assert_eq!(auto_scope_for_dir(&root), ProjectScope::Global);
assert_eq!(auto_scope_for_dir(&root).unwrap(), ProjectScope::Global);

std::fs::remove_dir_all(root).unwrap();
}
Expand Down
Loading
Loading