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('#') { } else if key.contains('#') {
let (file, section) = key.split_once('#').unwrap(); let (file, section) = key.split_once('#').unwrap();
if let Some(bare) = file.strip_suffix(".md") { 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 bytes = fs::read(path).map_err(|e| format!("read {}: {}", path, e))?;
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher); 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). /// 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) /// Get the set of all mined transcript keys (both content-hash and filename)
/// from the store. Load once per daemon tick, check many. /// from the store. Load once per daemon tick, check many.
pub fn mined_transcript_keys() -> HashSet<String> { 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(); let mut hasher = DefaultHasher::new();
transcript_bytes.hash(&mut hasher); transcript_bytes.hash(&mut hasher);
let hash = hasher.finish(); 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) { if store.nodes.contains_key(&dedup_key) {
// Backfill per-segment key if called with a specific segment // Backfill per-segment key if called with a specific segment
@ -390,9 +390,9 @@ pub fn experience_mine(
.to_lowercase() .to_lowercase()
.replace(' ', "-"); .replace(' ', "-");
let key = if ts.is_empty() { let key = if ts.is_empty() {
format!("journal#j-mined-{}", key_slug) format!("journal-j-mined-{}", key_slug)
} else { } else {
format!("journal#j-{}-{}", ts.to_lowercase().replace(':', "-"), key_slug) format!("journal-j-{}-{}", ts.to_lowercase().replace(':', "-"), key_slug)
}; };
// Check for duplicate // Check for duplicate
@ -413,6 +413,25 @@ pub fn experience_mine(
let _ = store.upsert_node(node); let _ = store.upsert_node(node);
count += 1; 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, "..."); let preview = crate::util::truncate(content, 77, "...");
println!(" + [{}] {}", ts, preview); 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(); let projects = crate::config::get().projects_dir.clone();
if !projects.exists() { return Vec::new(); } 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(); let mut jsonl_files: Vec<PathBuf> = Vec::new();
if let Ok(dirs) = fs::read_dir(&projects) { if let Ok(dirs) = fs::read_dir(&projects) {

View file

@ -36,16 +36,25 @@ Each entry should be 80-200 words. Quality over quantity.
## Output format ## Output format
Return a JSON array of entries, each with timestamp and content: Return a JSON array of entries. Each entry has timestamp, content, and links
to existing semantic memory nodes that relate to this moment:
```json ```json
[ [
{ {
"timestamp": "2026-03-01T01:15", "timestamp": "2026-03-01T01:15",
"content": "Journal entry text here.\n\nwarmth:8 curiosity:7" "content": "Journal entry text here.\n\nwarmth:8 curiosity:7",
"links": ["existing-node-key", "another-relevant-key"]
} }
] ]
``` ```
For the `links` field: look at the semantic memory nodes listed below and pick
any that relate to this moment. A journal entry about intimacy should link to
`inner-life-sexuality-intimacy`. An insight about code should link to the
relevant `patterns-*` or `practices-*` node. 2-5 links per entry is ideal.
If nothing fits, use an empty array.
Return `[]` if there's nothing worth capturing that isn't already journaled. Return `[]` if there's nothing worth capturing that isn't already journaled.
--- ---