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
1,133 changes: 1,118 additions & 15 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ members = [
"crates/frameshift-compose",
"crates/frameshift-conformance",
"crates/frameshift-daemon",
"crates/frameshift-embed-candle",
"crates/frameshift-growth",
"crates/frameshift-mcp",
"crates/frameshift-memory",
Expand Down
6 changes: 6 additions & 0 deletions crates/frameshift-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ frameshift-source = { path = "../frameshift-source" }
frameshift-growth = { path = "../frameshift-growth" }
frameshift-conformance = { path = "../frameshift-conformance" }
frameshift-orchestrator = { path = "../frameshift-orchestrator" }
frameshift-embed-candle = { path = "../frameshift-embed-candle", optional = true }
clap = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }

[features]
# Semantic selection via a local candle sentence-embedding model. Off by
# default so the standard build stays free of the ML dependency stack.
embeddings = ["dep:frameshift-embed-candle"]

[dev-dependencies]
tempfile = { workspace = true }
34 changes: 30 additions & 4 deletions crates/frameshift-cli/src/cmd/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,31 @@ use std::path::PathBuf;

use clap::Args;
use frameshift_client::Client;
use frameshift_orchestrator::{PolicyWeights, Preferences, SelectionInputs};
use frameshift_orchestrator::{Embedder, PolicyWeights, Preferences, SelectionInputs};

use crate::util::CliError;

/// Build the semantic embedder when the `embeddings` feature is enabled.
///
/// Model download or load failures degrade to `None` with a stderr warning,
/// so selection falls back to the lexical channels instead of failing.
#[cfg(feature = "embeddings")]
fn make_embedder() -> Option<Box<dyn Embedder>> {
match frameshift_embed_candle::CandleEmbedder::from_hub() {
Ok(embedder) => Some(Box::new(embedder)),
Err(e) => {
eprintln!("warning: semantic embeddings unavailable ({e}); using lexical ranking only");
None
}
}
}

/// Without the `embeddings` feature there is never an embedder.
#[cfg(not(feature = "embeddings"))]
fn make_embedder() -> Option<Box<dyn Embedder>> {
None
}

/// Arguments for the `select` subcommand.
#[derive(Debug, Args)]
pub struct SelectArgs {
Expand Down Expand Up @@ -72,17 +93,22 @@ pub fn run_select(client: &Client, args: SelectArgs) -> Result<(), CliError> {
weights: PolicyWeights::default(),
};

// Semantic channel: present only when built with the `embeddings` feature
// and the model loads; otherwise ranking is purely lexical/contextual.
let embedder = make_embedder();

if args.format == "json" {
// Emit the full SelectionOutput as structured JSON.
let output = frameshift_orchestrator::select_rich(&inputs)
.map_err(|e| CliError::Orchestrator(e.to_string()))?;
let output =
frameshift_orchestrator::select_rich_with_embedder(&inputs, embedder.as_deref())
.map_err(|e| CliError::Orchestrator(e.to_string()))?;
let json = serde_json::to_string_pretty(&output)?;
println!("{}", json);
return Ok(());
}

// Default: table format using the ranked candidate list.
let ranked = frameshift_orchestrator::select(&inputs)
let ranked = frameshift_orchestrator::select_with_embedder(&inputs, embedder.as_deref())
.map_err(|e| CliError::Orchestrator(e.to_string()))?;

if ranked.is_empty() {
Expand Down
6 changes: 6 additions & 0 deletions crates/frameshift-daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ path = "src/main.rs"
frameshift-client = { path = "../frameshift-client" }
frameshift-growth = { path = "../frameshift-growth" }
frameshift-orchestrator = { path = "../frameshift-orchestrator" }
frameshift-embed-candle = { path = "../frameshift-embed-candle", optional = true }
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["full"] }
Expand All @@ -26,3 +27,8 @@ libc = "0.2"

[dev-dependencies]
tempfile.workspace = true

[features]
# Semantic selection via a local candle sentence-embedding model. Off by
# default so the standard build stays free of the ML dependency stack.
embeddings = ["dep:frameshift-embed-candle"]
32 changes: 30 additions & 2 deletions crates/frameshift-daemon/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,40 @@ use frameshift_client::Client;
use frameshift_orchestrator::{
audit::{now_timestamp, AuditLog, Transition},
controller::{Decision, SwitchController, SwitchPolicy},
embed::Embedder,
feedback::Preferences,
mode::{Mode, ModeState},
policy::PolicyWeights,
run::{select, SelectionInputs},
run::{select_with_embedder, SelectionInputs},
};

/// Return the process-wide semantic embedder, loading the model once on first
/// use. A failed load (offline, corrupt cache) is remembered as `None` so the
/// daemon does not retry the download every evaluation tick.
#[cfg(feature = "embeddings")]
fn shared_embedder() -> Option<&'static dyn Embedder> {
use std::sync::OnceLock;
static EMBEDDER: OnceLock<Option<frameshift_embed_candle::CandleEmbedder>> = OnceLock::new();
EMBEDDER
.get_or_init(
|| match frameshift_embed_candle::CandleEmbedder::from_hub() {
Ok(e) => Some(e),
Err(e) => {
tracing::warn!(error = %e, "semantic embeddings unavailable; lexical ranking only");
None
}
},
)
.as_ref()
.map(|e| e as &dyn Embedder)
}

/// Without the `embeddings` feature there is never an embedder.
#[cfg(not(feature = "embeddings"))]
fn shared_embedder() -> Option<&'static dyn Embedder> {
None
}

/// Read the persona name recorded in the project's active marker, if any.
///
/// Returns `None` when the marker is absent, unreadable, or empty after
Expand Down Expand Up @@ -123,7 +151,7 @@ pub fn evaluate_and_apply(client: &Client, controller: &mut SwitchController, pr
weights: PolicyWeights::default(),
};

let ranked = match select(&inputs) {
let ranked = match select_with_embedder(&inputs, shared_embedder()) {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "orchestrator: selection failed");
Expand Down
21 changes: 21 additions & 0 deletions crates/frameshift-embed-candle/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "frameshift-embed-candle"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Candle-powered sentence embeddings (all-MiniLM-L6-v2) implementing the orchestrator's Embedder trait for semantic persona selection."

[dependencies]
frameshift-orchestrator = { path = "../frameshift-orchestrator" }
# Pure-Rust inference stack: no native ONNX runtime to package per platform.
candle-core = "0.11"
candle-nn = "0.11"
candle-transformers = "0.11"
# HuggingFace tokenizer files (tokenizer.json) for the BERT wordpiece vocab.
tokenizers = "0.23"
# Model download-on-first-use with a local cache shared across projects.
hf-hub = "0.5"
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
Loading
Loading