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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ synapse connect
synapse
```

`synapse connect` connects installed tools. Restart them afterward. Use `synapse status` for text status; the same binary provides the CLI and MCP server. The Linux build has no desktop UI dependency and uses the encrypted vault instead of macOS Keychain. Current release downloads contain the macOS app; Linux users build from source.
`synapse connect` connects installed tools. Restart them afterward. Use `synapse status` for text status; the same binary provides the CLI and MCP server. The Linux build has no desktop UI dependency and uses the encrypted vault instead of macOS Keychain. Release downloads contain the macOS app and Windows installer; Linux users build from source.

See the [installation guide](https://wess.io/synapse/docs/install/) for CLI setup and connection troubleshooting.

Expand Down
2 changes: 1 addition & 1 deletion crates/synapse/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "synapse"
# Bumping this on main is the release trigger. Keep it in step with
# crates/synapsecore, which is where `synapse version` reads its number from.
version = "1.11.0"
version = "1.12.0"
edition.workspace = true
description = "Local memory and scoped credentials for developer tools"

Expand Down
5 changes: 3 additions & 2 deletions crates/synapse/src/ui/savings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,9 @@ fn empty(border: Hsla, surface: Hsla) -> AnyElement {
fn footnote(report: &Report) -> AnyElement {
let since = report.since.as_deref().unwrap_or_default();
Text::new(format!(
"Counting since {since} on this machine. Tokens are characters ÷ {}, counted once per session, so treat these as a floor.",
usage::PERTOKEN
"Counting since {since} on this machine. Usage older than {} days is pruned. Tokens are characters ÷ {}, counted once per session, so treat these as a floor.",
usage::RETENTIONDAYS,
usage::PERTOKEN,
))
.size(Size::Xs)
.dimmed()
Expand Down
2 changes: 1 addition & 1 deletion crates/synapsecore/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/synapsecore/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ name = "synapsecore"
# Keep this in step with crates/synapse: `synapse version` reads the version of
# the crate that holds the command, and the two ship as one product. Only the
# desktop crate's version triggers the release workflow.
version = "1.11.0"
version = "1.12.0"
edition = "2024"
description = "Memory, credentials, and the command surface for Synapse, without a window"

Expand Down
42 changes: 42 additions & 0 deletions crates/synapsecore/examples/retrieval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use serde_json::{Value, json};
use std::future::Future;
use std::time::Instant;
use synapsecore::brain::{Brain, Optimization, RecallResponse};
use synapsecore::usage::{self, Entry};

#[tokio::main]
async fn main() -> Result<()> {
Expand Down Expand Up @@ -56,6 +57,47 @@ async fn main() -> Result<()> {
.await?,
);
}
measurements.push(
measure("recall_with_drift", iterations, || async {
let (settings, memories) = brain
.recallscoped(
"database migrations scheduling durability",
4,
Some(Optimization::Lean),
None,
)
.await?;
let drift = brain.drifted(&memories, None).await?;
let response = RecallResponse {
optimization: settings.optimization,
memories: memories.into_iter().map(Into::into).collect(),
drift,
};
Ok(serde_json::to_vec(&response)?.len())
})
.await?,
);
let (_, sample) = brain
.recallscoped(
"database migrations scheduling durability",
4,
Some(Optimization::Lean),
None,
)
.await?;
measurements.push(
measure("drift_only", iterations, || async {
Ok(serde_json::to_vec(&brain.drifted(&sample, None).await?)?.len())
})
.await?,
);
measurements.push(
measure("usage_record", iterations, || async {
usage::record(&brain, Entry::session(100_000, 2_000)).await?;
Ok(0)
})
.await?,
);
measurements.push(
measure("readmemory", iterations, || async {
let response = brain
Expand Down
92 changes: 80 additions & 12 deletions crates/synapsecore/src/brain/drift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,18 @@ use std::path::{Path, PathBuf};
/// Newest memories read by a full check.
pub const SCANCEILING: i64 = 5_000;

/// Maximum pair checks and findings in one advisory scan.
const PAIRCEILING: usize = 50_000;
const PROBECEILING: usize = 200_000;
const FINDINGCEILING: usize = 1_000;

/// Candidates read when looking for one memory's counterparts. A contradiction
/// shares most of its words, so it ranks near the top.
const COUNTERPARTS: i64 = 8;

/// Recent visible memories checked for a correction the query did not match.
const RECENT: i64 = 256;

/// Words from the memory used for that search.
const PROBE: usize = 16;

Expand Down Expand Up @@ -67,6 +75,8 @@ pub struct Drift {
pub scanned: usize,
/// More than [`SCANCEILING`] memories, so only the newest were read.
pub capped: bool,
/// A dense set of similar memories hit the pair or finding safety limit.
pub partial: bool,
pub findings: Vec<Finding>,
}

Expand All @@ -85,12 +95,14 @@ impl Brain {
.context("could not read memory to check")?;
let capped = memories.len() as i64 > SCANCEILING;
let memories = &memories[..memories.len().min(SCANCEILING as usize)];
let (findings, partial) = scanlimited(memories, &checkout);
Ok(Drift {
root: checkout.root().map(Path::to_path_buf),
project,
scanned: memories.len(),
capped,
findings: scan(memories, &checkout),
partial,
findings,
})
}
}
Expand All @@ -111,13 +123,44 @@ impl Brain {
.flatten()
.unwrap_or_default();
let mut findings = scan(memories, &checkout);
if memories.is_empty() {
return Ok(findings);
}
let present: HashSet<i64> = memories.iter().map(|memory| memory.id).collect();
let earliest = memories
.iter()
.map(|memory| memory.created)
.min()
.unwrap_or_default();
let recent = sqlx::query_as::<_, Memory>(&format!(
"SELECT {COLUMNS} FROM memorymeta meta JOIN memory ON memory.rowid = meta.memoryid \
WHERE {VISIBLE} AND meta.created >= ? \
ORDER BY meta.created DESC, meta.memoryid DESC LIMIT ?"
))
.bind(&project)
.bind(earliest)
.bind(RECENT)
.fetch_all(&self.pool)
.await
.context("could not check recent memory for corrections")?;
let profiles: Vec<Profile> = recent
.iter()
.map(|other| compare::profile(&other.body))
.collect();
for memory in memories {
for (other, relation) in self.counterparts(memory, &project).await? {
let newer = (other.created, other.id) > (memory.created, memory.id);
if present.contains(&other.id) || !newer {
let own = compare::profile(&memory.body);
if !own.comparable() {
continue;
}
for (other, profile) in recent.iter().zip(&profiles) {
if present.contains(&other.id)
|| (other.created, other.id) <= (memory.created, memory.id)
{
continue;
}
let Some(relation @ Relation::Conflict(_)) = compare::relate(&own, profile) else {
continue;
};
let mut finding = paired(memory, &other, relation);
finding.note.push_str(&format!(
" #{} is not in this response; it says: {}",
Expand All @@ -127,6 +170,7 @@ impl Brain {
if !findings.iter().any(|known| known.ids == finding.ids) {
findings.push(finding);
}
break;
}
}
findings.sort_by(|a, b| a.concern.cmp(&b.concern).then(a.ids.cmp(&b.ids)));
Expand Down Expand Up @@ -205,21 +249,36 @@ fn quoted(body: &str) -> String {
}

pub fn scan(memories: &[Memory], checkout: &Checkout) -> Vec<Finding> {
scanlimited(memories, checkout).0
}

fn scanlimited(memories: &[Memory], checkout: &Checkout) -> (Vec<Finding>, bool) {
let mut findings: Vec<Finding> = memories
.iter()
.filter_map(|memory| stale(memory, checkout))
.take(FINDINGCEILING + 1)
.collect();
if findings.len() > FINDINGCEILING {
findings.truncate(FINDINGCEILING);
return (findings, true);
}
let profiles: Vec<Profile> = memories
.iter()
.map(|memory| compare::profile(&memory.body))
.collect();
for (first, second) in candidates(&profiles) {
let (pairs, pairscapped) = candidates(&profiles);
let mut findingscapped = false;
for (first, second) in pairs {
if let Some(relation) = compare::relate(&profiles[first], &profiles[second]) {
findings.push(paired(&memories[first], &memories[second], relation));
if findings.len() == FINDINGCEILING {
findingscapped = true;
break;
}
}
}
findings.sort_by(|a, b| a.concern.cmp(&b.concern).then(a.ids.cmp(&b.ids)));
findings
(findings, pairscapped || findingscapped)
}

fn stale(memory: &Memory, checkout: &Checkout) -> Option<Finding> {
Expand Down Expand Up @@ -253,7 +312,7 @@ fn stale(memory: &Memory, checkout: &Checkout) -> Option<Finding> {
/// share one within the first `size - α + 1` of each. `relate` never reports a
/// pair with fewer than [`compare::MINSHARED`] shared words, so indexing just
/// those prefixes misses nothing, and rare words keep the posting lists short.
fn candidates(profiles: &[Profile]) -> Vec<(usize, usize)> {
fn candidates(profiles: &[Profile]) -> (Vec<(usize, usize)>, bool) {
let mut frequency: HashMap<&String, usize> = HashMap::new();
for profile in profiles.iter().filter(|profile| profile.comparable()) {
for word in profile.words() {
Expand All @@ -272,17 +331,26 @@ fn candidates(profiles: &[Profile]) -> Vec<(usize, usize)> {
postings.entry(word).or_default().push(index);
}
}
let mut pairs: Vec<(usize, usize)> = Vec::new();
for holders in postings.values() {
let mut ordered: Vec<_> = postings.into_iter().collect();
ordered.sort_by(|(a, ah), (b, bh)| ah.len().cmp(&bh.len()).then(a.cmp(b)));
let mut pairs: HashSet<(usize, usize)> = HashSet::new();
let mut probes = 0;
for (_, holders) in ordered {
for (position, first) in holders.iter().enumerate() {
for second in &holders[position + 1..] {
pairs.push((*first.min(second), *first.max(second)));
probes += 1;
pairs.insert((*first.min(second), *first.max(second)));
if pairs.len() == PAIRCEILING || probes == PROBECEILING {
let mut pairs: Vec<_> = pairs.into_iter().collect();
pairs.sort_unstable();
return (pairs, true);
}
}
}
}
let mut pairs: Vec<_> = pairs.into_iter().collect();
pairs.sort_unstable();
pairs.dedup();
pairs
(pairs, false)
}

fn paired(first: &Memory, second: &Memory, relation: Relation) -> Finding {
Expand Down
48 changes: 41 additions & 7 deletions crates/synapsecore/src/brain/drift/checkout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ impl Checkout {
/// pass true for memory from this project.
pub fn presence(&self, reference: &str, relative: bool) -> Presence {
// Build output comes and goes; an unbuilt binary isn't a stale memory.
if reference.split('/').any(|part| SKIPPED.contains(&part)) {
if reference
.split(['/', '\\'])
.any(|part| SKIPPED.contains(&part))
{
return Presence::Unknown;
}
if let Some(rest) = reference.strip_prefix("~/") {
Expand All @@ -92,22 +95,24 @@ impl Checkout {
None => Presence::Unknown,
};
}
if reference.starts_with('/') {
return self.absolute(Path::new(reference));
let path = Path::new(reference);
if path.is_absolute() {
return self.absolute(path);
}
let Some(root) = self.root.as_deref().filter(|_| relative) else {
return Presence::Unknown;
};
let wanted = reference.trim_end_matches('/');
let wanted = reference.trim_end_matches(['/', '\\']);
if root.join(wanted).exists() {
return Presence::Present;
}
// Paths are often written relative to a package, e.g.
// `src/cli/memory.rs` for `crates/core/src/cli/memory.rs`.
match self.index.get_or_init(|| walk(root)) {
Some(index) => {
let wanted = wanted.replace('\\', "/");
let suffix = format!("/{wanted}");
match index.contains(wanted) || index.iter().any(|path| path.ends_with(&suffix)) {
match index.contains(&wanted) || index.iter().any(|path| path.ends_with(&suffix)) {
true => Presence::Present,
false => Presence::Missing,
}
Expand All @@ -126,7 +131,16 @@ impl Checkout {
.is_some_and(|home| path.starts_with(home));
let foreign = ["/Users/", "/home/"]
.iter()
.any(|prefix| text.starts_with(prefix) && !own);
.any(|prefix| text.starts_with(prefix) && !own)
|| self.home.as_deref().is_some_and(|home| {
home.parent().is_some_and(|parent| {
parent
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("users") || name == "home")
&& path.starts_with(parent)
&& !own
})
});
let transient = !own && TRANSIENT.iter().any(|prefix| text.starts_with(prefix));
match foreign || transient {
true => Presence::Unknown,
Expand Down Expand Up @@ -282,8 +296,28 @@ mod tests {
checkout.presence(&own.to_string_lossy(), false),
Presence::Present
);
#[cfg(windows)]
let missing = std::env::current_dir()
.unwrap()
.join("definitely-not-present.rs");
#[cfg(not(windows))]
let missing = PathBuf::from("/definitely/not/here.rs");
assert_eq!(
checkout.presence("/definitely/not/here.rs", false),
checkout.presence(&missing.to_string_lossy(), false),
Presence::Missing
);
}

#[cfg(windows)]
#[test]
fn windows_absolute_paths_and_other_accounts_home() {
let checkout = Checkout::new(None, None, Some(PathBuf::from("C:\\Users\\me")));
assert_eq!(
checkout.presence("C:\\Users\\somebodyelse\\project\\a.rs", false),
Presence::Unknown
);
assert_eq!(
checkout.presence("C:\\definitely\\not\\here.rs", false),
Presence::Missing
);
}
Expand Down
Loading
Loading