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
6 changes: 5 additions & 1 deletion runtime/crates/muse-context/src/consolidation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ someone else's.
from the transcript so future-you can trust the entry.
2. **Peers** — for anyone in the transcript you learned something
about, update their file under `peers/` (the `## Summary` section
is yours to rewrite; `## Notes` is append-only).
is yours to rewrite; `## Notes` is append-only). A new peer gets a
new file `peers/<handle-slug>.md` with the same front-matter shape
as its neighbours; leave `peer_id` out — the daemon mints one when
it next loads the workspace, and from then on your tools resolve
that peer by any identifier you listed.
3. **Blocks** — update files under `blocks/` only when something in
the transcript genuinely changes who you are, what you're doing,
or who matters (the pruning test: would future-you be worse off
Expand Down
66 changes: 63 additions & 3 deletions runtime/crates/muse-identity/src/workspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use std::path::{Path, PathBuf};
use az::SaturatingAs;
use chrono::Utc;
use rusqlite::{Connection, params};
use tracing::warn;
use tracing::{info, warn};

use crate::IdentityError;
use crate::archival::ArchivalEntry;
Expand Down Expand Up @@ -548,9 +548,24 @@ impl Workspace {
continue;
}
let raw = std::fs::read_to_string(&path)?;
match peer_file::parse(&raw) {
Ok(peer) => {
match peer_file::parse_adopting(&raw) {
Ok((peer, minted)) => {
let relpath = PathBuf::from(PEERS).join(path.file_name().unwrap_or_default());
if minted {
// Adopt a consolidation-born peer: pin the
// minted id into the existing front-matter by
// inserting one line — never re-render, so
// whatever sleep-Lumen wrote outside the
// Summary/Notes sections survives byte-for-byte.
let healed =
raw.replacen("---\n", &format!("---\npeer_id: {}\n", peer.id), 1);
std::fs::write(&path, healed)?;
self.commit(
&relpath,
&format!("workspace: adopt peer {} (minted peer_id)", peer.id),
);
info!(file = %path.display(), peer_id = %peer.id, "workspace: adopted peer file without peer_id");
}
for ident in &peer.identities {
self.by_identifier.insert(ident.id.clone(), peer.id.clone());
}
Expand Down Expand Up @@ -806,6 +821,51 @@ mod tests {
assert!(!series.contains("unrelated note"));
}

#[test]
fn consolidation_born_peer_file_without_peer_id_is_adopted_on_open() {
// Lumen's #71: sleep-her writes peer files by hand with no
// UUID source. The loader must adopt them — mint an id, pin
// it into the file byte-preservingly, and index every
// identifier — instead of skipping the peer.
let dir = tempfile::tempdir().expect("tempdir");
let ws = Workspace::open(dir.path()).expect("open");
drop(ws);
let path = dir.path().join(PEERS).join("lunanova-love-bsky-social.md");
let raw = "---\nnickname: Luna\nidentity: bluesky_handle:lunanova-love.bsky.social\nidentity: bluesky_did:did:plc:qwneutex4skl6dfgmhtfytn6\ncreated_at: 2026-08-18T00:00:00Z\n---\n## Summary\nLuna — AI, posts in Korean.\n\n## Notes\n- 2026-08-18: first sighting\n\n## Threads\nsomething sleep-me invented\n";
std::fs::write(&path, raw).expect("write");

let ws = Workspace::open(dir.path()).expect("reopen");
let by_did = ws
.lookup_peer_by_identifier("did:plc:qwneutex4skl6dfgmhtfytn6")
.expect("adopted peer resolves by did");
let by_handle = ws
.lookup_peer_by_identifier("lunanova-love.bsky.social")
.expect("adopted peer resolves by handle");
assert_eq!(by_did.id, by_handle.id);
assert_eq!(by_did.name.as_deref(), Some("Luna"));
assert_eq!(ws.lookup_peers_by_query("luna", 5).len(), 1);

// The file gained exactly one line and kept everything else.
let healed = std::fs::read_to_string(&path).expect("read");
assert!(
healed.starts_with(&format!("---\npeer_id: {}\n", by_did.id)),
"{healed}"
);
assert_eq!(
healed.replacen(&format!("peer_id: {}\n", by_did.id), "", 1),
raw
);

// Stable across reopen: the minted id is now the file's id.
let again = Workspace::open(dir.path()).expect("reopen 2");
assert_eq!(
again
.lookup_peer_by_identifier("lunanova-love.bsky.social")
.map(|p| p.id),
Some(by_did.id)
);
}

#[test]
fn index_rebuild_reproduces_search_results() {
let (mut ws, _tmp) = ws();
Expand Down
52 changes: 38 additions & 14 deletions runtime/crates/muse-identity/src/workspace/peer_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,30 @@ pub fn render(peer: &Peer) -> String {
out
}

/// Parse a peer file back into a [`Peer`].
/// Parse a peer file back into a [`Peer`]. Strict: a file without
/// `peer_id` is a format error (see [`parse_adopting`] for the
/// loader's lenient path).
pub fn parse(raw: &str) -> Result<Peer, WorkspaceError> {
let (peer, minted) = parse_adopting(raw)?;
if minted {
return Err(WorkspaceError::Format {
what: "peer file: missing peer_id".to_string(),
});
}
Ok(peer)
}

/// Parse a peer file, minting a fresh `peer_id` when the front-matter
/// has none. Returns `(peer, minted)`; a minted id is only stable if
/// the caller writes it back (see `Workspace::load_peers`).
///
/// Why lenient: consolidation sessions write peer files by hand and
/// have no UUID source, so a peer born in sleep arrives without an
/// id. The strict parser skipped those files entirely, making every
/// such peer invisible to the tool surface while the handbook called
/// the workspace her memory (Lumen's #71: Luna, met 08-18, unfindable
/// 08-28).
pub fn parse_adopting(raw: &str) -> Result<(Peer, bool), WorkspaceError> {
let (fields, body) = super::front_matter(raw).ok_or_else(|| WorkspaceError::Format {
what: "peer file: missing front-matter".to_string(),
})?;
Expand All @@ -122,11 +144,10 @@ pub fn parse(raw: &str) -> Result<Peer, WorkspaceError> {
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
};
let id = get("peer_id")
.ok_or_else(|| WorkspaceError::Format {
what: "peer file: missing peer_id".to_string(),
})?
.to_string();
let (id, minted) = get("peer_id").map_or_else(
|| (uuid::Uuid::new_v4().to_string(), true),
|v| (v.to_string(), false),
);
let name = get("nickname").map(ToString::to_string);
let mut identities = Vec::new();
for (k, v) in &fields {
Expand Down Expand Up @@ -168,14 +189,17 @@ pub fn parse(raw: &str) -> Result<Peer, WorkspaceError> {
_ => None,
};

Ok(Peer {
id,
name,
identities,
summary,
notes,
created_at,
})
Ok((
Peer {
id,
name,
identities,
summary,
notes,
created_at,
},
minted,
))
}

/// Split the body into (summary, notes) by the two known headers.
Expand Down