experience-mine: link at creation time, remove # from new keys

Update the experience mining prompt to output links alongside journal
entries. The LLM now returns a "links" array per entry pointing to
existing semantic nodes. Rust code creates the links immediately after
node creation — new nodes arrive pre-connected instead of orphaned.

Also: remove # from all key generation paths (experience miner,
digest section keys, observed transcript keys). New nodes get clean
dash-separated keys.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
ProofOfConcept 2026-03-14 16:25:31 -04:00
parent ce94e1cac1
commit 83342897c8
4 changed files with 37 additions and 9 deletions

View file

@ -365,7 +365,7 @@ fn normalize_link_key(raw: &str) -> String {
} else if key.contains('#') {
let (file, section) = key.split_once('#').unwrap();
if let Some(bare) = file.strip_suffix(".md") {
key = format!("{}#{}", bare, section);
key = format!("{}-{}", bare, section);
}
}

View file

@ -26,7 +26,7 @@ fn transcript_dedup_key(path: &str) -> Result<String, String> {
let bytes = fs::read(path).map_err(|e| format!("read {}: {}", path, e))?;
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
Ok(format!("_mined-transcripts#h-{:016x}", hasher.finish()))
Ok(format!("_mined-transcripts-h-{:016x}", hasher.finish()))
}
/// Check if a transcript has already been mined (dedup key exists in store).
@ -111,7 +111,7 @@ pub fn mark_segment(
/// Get the set of all mined transcript keys (both content-hash and filename)
/// from the store. Load once per daemon tick, check many.
pub fn mined_transcript_keys() -> HashSet<String> {
keys_with_prefix("_mined-transcripts#")
keys_with_prefix("_mined-transcripts-")
}
@ -286,7 +286,7 @@ pub fn experience_mine(
let mut hasher = DefaultHasher::new();
transcript_bytes.hash(&mut hasher);
let hash = hasher.finish();
let dedup_key = format!("_mined-transcripts#h-{:016x}", hash);
let dedup_key = format!("_mined-transcripts-h-{:016x}", hash);
if store.nodes.contains_key(&dedup_key) {
// Backfill per-segment key if called with a specific segment
@ -390,9 +390,9 @@ pub fn experience_mine(
.to_lowercase()
.replace(' ', "-");
let key = if ts.is_empty() {
format!("journal#j-mined-{}", key_slug)
format!("journal-j-mined-{}", key_slug)
} else {
format!("journal#j-{}-{}", ts.to_lowercase().replace(':', "-"), key_slug)
format!("journal-j-{}-{}", ts.to_lowercase().replace(':', "-"), key_slug)
};
// Check for duplicate
@ -413,6 +413,25 @@ pub fn experience_mine(
let _ = store.upsert_node(node);
count += 1;
// Apply links from LLM output
if let Some(links) = entry.get("links").and_then(|v| v.as_array()) {
for link_val in links {
if let Some(target) = link_val.as_str() {
let target = target.to_string();
if let Some(target_node) = store.nodes.get(&target) {
let source_uuid = store.nodes.get(&key).map(|n| n.uuid).unwrap_or_default();
let target_uuid = target_node.uuid;
let rel = store::new_relation(
source_uuid, target_uuid,
store::RelationType::Link, 0.3,
&key, &target,
);
let _ = store.add_relation(rel);
}
}
}
}
let preview = crate::util::truncate(content, 77, "...");
println!(" + [{}] {}", ts, preview);
}

View file

@ -684,7 +684,7 @@ pub fn select_conversation_fragments(n: usize) -> Vec<(String, String)> {
let projects = crate::config::get().projects_dir.clone();
if !projects.exists() { return Vec::new(); }
let observed = super::enrich::keys_with_prefix(&format!("{}#", OBSERVED_PREFIX));
let observed = super::enrich::keys_with_prefix(&format!("{}-", OBSERVED_PREFIX));
let mut jsonl_files: Vec<PathBuf> = Vec::new();
if let Ok(dirs) = fs::read_dir(&projects) {