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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ The **one typed API**. The CLI and the desktop host are its only clients; every
module is called directly only by the integration tests. Surface: lifecycle + indexing (`open` /
`open_with_embedder` / `reindex` / `reindex_with_progress` / `plan_reindex` / `project` / `embed`),
reads (`read` / `list_notes` / `neighbors` / `explain` / `search` / `similar`), writes (`add_note` /
`move_note` / `link` / `write`). **Add operations when a command needs them; do not pre-build a broad
surface.** The embedder is injected here: `open` defaults to the fake, `open_with_embedder` is how the
`create_note` / `move_note` / `link` / `write`). **Add operations when a command needs them; do not
pre-build a broad surface.** The embedder is injected here: `open` defaults to the fake, `open_with_embedder` is how the
adapters wire the real model.

### Data flows
Expand Down
59 changes: 48 additions & 11 deletions crates/b2-core/src/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,27 +58,64 @@ pub fn add_note(
content: Option<&str>,
created: &str,
) -> Result<AddReport> {
let rel = write_new_note(vault_root, path_input, title, content, created)?;

// 2. Project from that Markdown: stamp the `b2id`, chunk + embed the body, and
// derive any edges its content authors.
let ingested = ingest::ingest_file(conn, vault_root, &rel, idgen, embedder)?;
Ok(AddReport {
b2id: ingested.b2id,
path: rel,
})
}

/// The **model-free** sibling of [`add_note`] — the desktop's New-note action
/// (`Vault::create_note`): same file write, but the projection is
/// [`ingest::project_file`] (chunks + FTS + edges, **no embedder**), the same pass
/// `Vault::write` runs after a save. The new note's chunks join the DB-derived
/// missing-vector set for any later embed/reindex to fill
/// (projection-embedding-split.md §7.2) — and a body-less note has nothing to
/// embed anyway. Same validation and refusals as [`add_note`].
pub fn create_note(
conn: &Connection,
idgen: &dyn IdGen,
vault_root: &Path,
path_input: &str,
title: Option<&str>,
content: Option<&str>,
created: &str,
) -> Result<AddReport> {
let rel = write_new_note(vault_root, path_input, title, content, created)?;
let projected = ingest::project_file(conn, vault_root, &rel, idgen)?;
Ok(AddReport {
b2id: projected.b2id,
path: rel,
})
}

/// The shared create step: validate `path_input`, refuse to clobber, render the
/// minimal frontmatter + body, and write the new file (creating missing parent
/// dirs). Markdown first (step 1 of both entry points) — the `b2id` is deliberately
/// left off; ingest/projection stamps it on first sight (§1). Returns the
/// vault-relative `.md` path.
fn write_new_note(
vault_root: &Path,
path_input: &str,
title: Option<&str>,
content: Option<&str>,
created: &str,
) -> Result<String> {
let rel = crate::pathspec::normalize_rel_md(path_input).map_err(Error::AddDestination)?;
let abs = vault_root.join(&rel);
if abs.exists() {
return Err(Error::AddTargetExists(rel));
}

// 1. Markdown first: write the new file (creating any missing parent dirs). The
// `b2id` is deliberately left off — ingest stamps it on first sight (§1).
let doc = render_note(title, content, created);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&abs, doc)?;

// 2. Project from that Markdown: stamp the `b2id`, chunk + embed the body, and
// derive any edges its content authors.
let ingested = ingest::ingest_file(conn, vault_root, &rel, idgen, embedder)?;
Ok(AddReport {
b2id: ingested.b2id,
path: rel,
})
Ok(rel)
}

/// Render a new note's text: a minimal valid frontmatter block followed by the body.
Expand Down
29 changes: 27 additions & 2 deletions crates/b2-core/src/vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
//! (and future adapters) are the sole clients of. It owns the open connection, the
//! embedder, and the id generator, and exposes *only what the shipped commands need*
//! — `open` / `reindex` / `project` / `embed` / `read` / `write` / `neighbors` /
//! `explain` / `search` / `similar` / `link` / `add` / `mv`. Add operations when a
//! command needs them; do not pre-build a sprawling surface.
//! `explain` / `search` / `similar` / `link` / `add` / `create` / `mv`. Add
//! operations when a command needs them; do not pre-build a sprawling surface.
//!
//! A vault is one portable folder: the index lives under `<root>/.b2/` (there is no
//! durable state outside the Markdown — data-model.md §4), so pointing B2 at a folder
Expand Down Expand Up @@ -1042,6 +1042,31 @@ impl Vault {
)
}

/// Create a new, empty note **model-free** — the desktop's New-note action
/// (its tree affordance / ⌘N), the create sibling of [`write`](Self::write):
/// write `path` with the same minimal frontmatter as [`add_note`](Self::add_note)
/// (no title — a note's display title is its filename, data-model.md §1) and
/// project it via [`ingest::project_file`] with **no embedder touched**, so
/// creation works with no model provisioned and a fake-opened vault can never
/// write foreign vectors into a real-model embedding space. The note's chunks
/// join the DB-derived missing-vector set, healed by any later
/// [`embed`](Self::embed)/reindex (projection-embedding-split.md §7.2) — and an
/// empty body has nothing to embed anyway. Same refusals as `add_note`:
/// [`Error::AddDestination`] / [`Error::AddTargetExists`].
pub fn create_note(&self, path: &str) -> Result<AddReport> {
let _op = tracing::debug_span!(target: "b2::vault", "create", path).entered();
let created = self.today()?;
add::create_note(
&self.conn,
&self.idgen,
&self.root,
path,
None,
None,
&created,
)
}

/// Today's date (`YYYY-MM-DD`) from **SQLite** — the same clock that stamps
/// `indexed_at`, so `b2-core` needs no wall-clock crate and the façade is the
/// determinism boundary (as it is for `idgen`). The vault convention for a note's
Expand Down
55 changes: 55 additions & 0 deletions crates/b2-core/tests/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,61 @@ fn add_refuses_to_clobber_an_existing_file() {
);
}

#[test]
fn create_note_writes_a_stamped_minimal_note_model_free() {
let tmp = tempfile::TempDir::new().unwrap();
let (vault, root) = reindexed(tmp.path());
let before = vault.embed_status().unwrap();

let report = vault.create_note("inbox/idea").unwrap();
assert_eq!(report.path, "inbox/idea.md");
assert!(!report.b2id.is_empty());

// On disk: the minimal frontmatter (no title — the display title is the
// filename, data-model.md §1), stamped, body-less, in a freshly-created dir.
let text = fs::read_to_string(root.join("inbox/idea.md")).unwrap();
assert!(text.contains(&format!("b2id: {}", report.b2id)), "{text}");
assert!(text.contains("type: note"), "{text}");
assert!(text.contains("created:"), "{text}");
assert!(!text.contains("title:"), "{text}");

// Projected: it resolves by path and b2id, and the tree lists it.
assert!(vault.explain("inbox/idea").is_ok());
assert!(vault.explain(&report.b2id).is_ok());
assert!(vault
.list_notes()
.unwrap()
.iter()
.any(|n| n.path == "inbox/idea.md"));

// Model-free: the embedding space is untouched — coverage gains no embedded
// note (an empty body has no chunks; a later embed/reindex owns any vectors).
let after = vault.embed_status().unwrap();
assert_eq!(
after.embedded, before.embedded,
"create_note must never embed"
);
assert_eq!(after.total, before.total + 1);
}

#[test]
fn create_note_refuses_clobber_and_invalid_paths() {
let tmp = tempfile::TempDir::new().unwrap();
let (vault, _root) = reindexed(tmp.path());

let err = vault.create_note("concepts/memory").unwrap_err();
assert!(matches!(err, Error::AddTargetExists(p) if p == "concepts/memory.md"));
for bad in ["../escape", "/abs/path", " "] {
assert!(
matches!(
vault.create_note(bad).unwrap_err(),
Error::AddDestination(_)
),
"path {bad:?} must be rejected"
);
}
}

#[test]
fn add_rejects_an_invalid_path() {
let tmp = tempfile::TempDir::new().unwrap();
Expand Down
6 changes: 5 additions & 1 deletion crates/b2-core/tests/explain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ fn explain_surfaces_outbound_resource_links() {
assert_eq!(r.caption.as_deref(), Some("a tiny diagram"));
assert!(r.embed, "an image embed reads as embed=true");
// A note with no file links reports an empty list, never an error.
assert!(vault.explain("concepts/memory").unwrap().resources.is_empty());
assert!(vault
.explain("concepts/memory")
.unwrap()
.resources
.is_empty());
}

#[test]
Expand Down
8 changes: 5 additions & 3 deletions crates/b2-desktop/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ add a UI concern to `b2-core`, that's the signal you're putting logic in the wro
([`Vault::open`](../b2-core/src/vault.rs)); anything that embeds a query or writes vectors (`search`,
`link`'s re-projection, `embed`) opens the real model
([`Vault::open_with_embedder`](../b2-core/src/vault.rs)) and fails fast with the "run `b2 init`"
message if it's absent. Two write-side ops are deliberately **model-free** and open the fake:
message if it's absent. Three write-side ops are deliberately **model-free** and open the fake:
`project` — the model-free half of a reindex
([specs/completed/projection-embedding-split.md](../../planning/specs/completed/projection-embedding-split.md) §6),
so the first tree paint never waits on a model load — and `write_note` — the save path
so the first tree paint never waits on a model load — `write_note` — the save path
([specs/completed/desktop-editing.md](../../planning/specs/completed/desktop-editing.md) §3), so editing works with no
model provisioned and saved chunks are healed by the trailing background embed.
model provisioned and saved chunks are healed by the trailing background embed — and
`create_note` — the tree's New-note action, the same posture as the save path (the new
note is projected immediately; its vectors fill on the next embed pass).
- **Errors stay generic to the webview.** Map façade errors to user-facing, actionable messages exactly as
the CLI funnels through `user_message` in [`b2-cli/src/main.rs`](../b2-cli/src/main.rs) — **never** leak
sqlite/io/serde internals into the UI. Use a `thiserror` enum for this crate's errors (matched → mapped),
Expand Down
71 changes: 71 additions & 0 deletions crates/b2-desktop/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use crate::error::CmdError;
use crate::watch::VaultWatcher;
use crate::{open_vault, AppState};
use b2_core::add::AddReport;
use b2_core::ingest::ReindexProgress;
use b2_core::vault::{
EmbedReport, ExplainView, LinkReport, NeighborView, NoteSummary, NoteView, ProjectReport,
Expand Down Expand Up @@ -162,6 +163,20 @@ pub fn write_note(
write_note_impl(state.inner(), &note, &body, &base_revision)
}

/// Create a new, empty note — the file tree's New-note action (and ⌘N). **Model-free**
/// like `write_note`/`project`: `Vault::create_note` writes the file and projects it
/// without touching vectors, so creating works with no model provisioned, and it runs
/// outside the single-in-flight embed slot (short, and a fake-opened vault must never
/// write into a real-model embedding space). The note's chunks join the DB-derived
/// pending set for the next embed pass — and an empty body has nothing to embed anyway.
/// Folders need no command: they exist only as file paths (the tree is index-derived),
/// so a staged folder materializes when its first note is created inside it
/// (`create_note` creates missing parent dirs, mirroring `b2 add`).
#[tauri::command(async)]
pub fn create_note(state: State<'_, AppState>, path: String) -> Result<AddReport, CmdError> {
create_note_impl(state.inner(), &path)
}

#[tauri::command(async)]
pub fn similar(
state: State<'_, AppState>,
Expand Down Expand Up @@ -448,6 +463,11 @@ fn write_note_impl(
Ok(vault.write(note, body, base_revision)?)
}

fn create_note_impl(state: &AppState, path: &str) -> Result<AddReport, CmdError> {
let (vault, _) = open_vault(state, false)?;
Ok(vault.create_note(path)?)
}

/// The testable core of `set_model`. `EmbedConfig::set_model` validates the id against
/// the registry *before* any filesystem write, so the unknown-model path is hermetic (no
/// file touched); the real-config write itself is exercised by `b2-embed`'s `write_model`
Expand Down Expand Up @@ -731,6 +751,57 @@ mod tests {
write_note_impl(&state, "concepts/memory", "Again.\n", &report.revision).unwrap();
}

#[test]
fn create_note_projects_and_lists_model_free() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("vault");
golden_indexed(&root);
let state = AppState::new(Some(root.clone()));

// Creating into a not-yet-existing folder materializes it (the staged-folder
// flow: the tree's "new folder" is UI state until its first note lands).
let report = create_note_impl(&state, "inbox/idea").unwrap();
assert_eq!(report.path, "inbox/idea.md");
assert!(root.join("inbox/idea.md").is_file());

// Immediately in the tree and readable (projected, no model touched).
let notes = list_notes_impl(&state).unwrap();
assert!(notes.iter().any(|n| n.path == "inbox/idea.md"));
let note = read_note_impl(&state, "inbox/idea").unwrap();
assert_eq!(note.body, "");
assert_eq!(note.b2id, report.b2id);
}

#[test]
fn create_note_refusals_stay_generic_and_actionable() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("vault");
golden_indexed(&root);
let state = AppState::new(Some(root));

// Clobber refusal names the path and the way out.
let err = create_note_impl(&state, "concepts/memory").unwrap_err();
assert!(matches!(
err,
CmdError::Core(b2_core::Error::AddTargetExists(_))
));
assert_eq!(
user_message(&err),
"A note already exists at 'concepts/memory.md'. Choose a different name, or open that note."
);

// An invalid destination is refused with actionable phrasing, no internals.
let err = create_note_impl(&state, "../escape").unwrap_err();
assert!(matches!(
err,
CmdError::Core(b2_core::Error::AddDestination(_))
));
assert_eq!(
user_message(&err),
"That note name isn't valid. Give a vault-relative name like `notes/new-idea`."
);
}

#[test]
fn write_conflict_is_generic_and_recognizable() {
let tmp = tempfile::TempDir::new().unwrap();
Expand Down
7 changes: 7 additions & 0 deletions crates/b2-desktop/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ pub fn user_message(err: &CmdError) -> String {
CmdError::Core(b2_core::Error::InvalidRelation(v)) => format!(
"'{v}' isn't a known relation type. Use one of: references, relates, elaborates, supports, refutes, contradicts, example-of, part-of, supersedes, derived-from."
),
CmdError::Core(b2_core::Error::AddTargetExists(p)) => format!(
"A note already exists at '{p}'. Choose a different name, or open that note."
),
CmdError::Core(b2_core::Error::AddDestination(_)) => {
"That note name isn't valid. Give a vault-relative name like `notes/new-idea`."
.to_string()
}
CmdError::Core(b2_core::Error::WriteConflict(_)) => {
"This note changed on disk since it was opened. Reload the note, then reapply your edit."
.to_string()
Expand Down
1 change: 1 addition & 0 deletions crates/b2-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ fn main() {
commands::explain_resource,
commands::open_resource,
commands::write_note,
commands::create_note,
commands::similar,
commands::search,
commands::neighbors,
Expand Down
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "node --experimental-strip-types src/panes.test.ts && node --experimental-strip-types src/graph.test.ts"
"test": "node --experimental-strip-types src/panes.test.ts && node --experimental-strip-types src/graph.test.ts && node --experimental-strip-types src/newentry.test.ts"
},
"dependencies": {
"@codemirror/commands": "^6.0.0",
Expand Down
9 changes: 9 additions & 0 deletions ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { Channel, invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
AddReport,
EmbedReport,
EmbedStat,
ExplainView,
Expand Down Expand Up @@ -106,6 +107,14 @@ export const api = {
writeNote: (note: string, body: string, baseRevision: string): Promise<WriteReport> =>
invoke("write_note", { note, body, baseRevision }),

/**
* Create a new, empty note at a vault-relative path (`.md` optional; missing
* parent folders are created — how a staged folder becomes real). Model-free
* like `writeNote`: the note is projected immediately (tree/search/graph) and
* its vectors fill on the next embed pass.
*/
createNote: (path: string): Promise<AddReport> => invoke("create_note", { path }),

/** A note's typed neighbors (both directions). */
neighbors: (note: string): Promise<NeighborView[]> => invoke("neighbors", { note }),

Expand Down
Loading