Compare commits

..

1 Commits

Author SHA1 Message Date
Tim Lingo 364ecff391 docs: design proposal — searchable, recency-aware conversation memory
Grounds the 'summarize my recent conversations returns nothing' issue: it's a
RETRIEVAL gap, not storage (conversations ARE persisted per-turn via auto_persist;
live engram has 59 conversation nodes). Proposes recency-windowed retrieval +
per-session threading + (roadmap) semantic search. No code — proposal for Tim + Will.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:03:38 -05:00
6 changed files with 111 additions and 63 deletions
+1 -4
View File
@@ -23,14 +23,11 @@ fn ise_post(content: String) -> Void {
let ise_url: String = env("SOUL_ISE_URL")
let engram_url: String = if str_eq(ise_url, "") { state_get("soul_engram_url") } else { ise_url }
if str_eq(engram_url, "") {
let local_id: String = engram_node_full(
let discard: String = engram_node_full(
content, "InternalStateEvent", "state-event",
el_from_float(0.3), el_from_float(0.3), el_from_float(0.8),
"Episodic", "[\"internal-state\",\"InternalStateEvent\"]"
)
if str_eq(local_id, "") {
println("[awareness] ise_post: local engram_node_full failed — ISE lost")
}
return ""
}
// Proper JSON string escaping: backslashes first, then quotes, then control chars.
+4 -31
View File
@@ -130,14 +130,11 @@ fn conv_history_persist(hist: String) -> Void {
if str_eq(hist, "[]") { return "" }
let ts: Int = time_now()
let tags: String = "[\"conv-history\",\"persistent\"]"
let node_id: String = engram_node_full(
let discard: String = engram_node_full(
hist, "Conversation", "conv:history",
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
"Episodic", tags
)
if str_eq(node_id, "") {
println("[chat] conv_history_persist: engram_node_full returned empty — history node lost")
}
}
// conv_history_load restore conversation history from engram on first access.
@@ -640,21 +637,6 @@ fn handle_chat_agentic(body: String) -> String {
// Thread-aware activation: same logic as handle_chat.
// Use the session's or global history to anchor short messages to the thread.
let req_session: String = json_get(body, "session_id")
// ISSUE #6/#7: validate that the session_id actually exists before proceeding.
// Without this check the loop silently treats any unknown/fabricated session_id
// as a fresh session history loads as empty and no error is returned to the caller.
// Only validate when a session_id is explicitly provided; anonymous calls
// (no session_id) continue to work for backward compatibility.
let session_valid: Bool = if str_eq(req_session, "") {
true
} else {
!str_contains(session_get(req_session), "\"error\"")
}
if !session_valid {
return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}"
}
let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session }
let agentic_hist: String = state_get(hist_key)
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
@@ -1072,19 +1054,13 @@ fn handle_dharma_room_turn(body: String) -> String {
// engram_node(content, "episodic", ...) which wrongly put a TIER into the node_type
// slot that's why nodes showed node_type="episodic". Use the full, correct contract.)
let utterance_tags: String = "[\"soul-utterance\",\"episodic\"]"
let utterance_id: String = engram_node_full(
let discard_id: String = engram_node_full(
clean_response, "Conversation", "soul:utterance",
el_from_float(0.6), el_from_float(0.6), el_from_float(0.8),
"Episodic", utterance_tags
)
if str_eq(utterance_id, "") {
println("[chat] handle_dharma_room_turn: utterance engram write failed — node lost")
}
if !str_eq(snap_path, "") {
let save_result: String = engram_save(snap_path)
if str_eq(save_result, "") {
println("[chat] handle_dharma_room_turn: engram_save failed for " + snap_path)
}
let discard_save: String = engram_save(snap_path)
}
let safe_response: String = json_safe(clean_response)
@@ -1166,7 +1142,7 @@ fn auto_persist(req: String, resp: String) -> Void {
+ ",\"label\":\"chat:" + ts_str + "\"}"
let tags: String = "[\"Conversation\",\"chat\",\"timestamped\"]"
let persist_id: String = engram_node_full(
engram_node_full(
content,
"Conversation",
"chat:" + ts_str,
@@ -1176,9 +1152,6 @@ fn auto_persist(req: String, resp: String) -> Void {
"Episodic",
tags
)
if str_eq(persist_id, "") {
println("[chat] auto_persist: engram_node_full returned empty — conversation node lost (ts=" + ts_str + ")")
}
}
// strengthen_chat_nodes strengthen the engram nodes that were activated during a chat.
+100
View File
@@ -0,0 +1,100 @@
# Design proposal: searchable, recency-aware conversation memory
Status: **proposal — for Tim + Will, no code yet**
Author: Neuron (Claude Opus 4.8), 2026-06-21
Trigger: "Summarize the key themes across my recent conversations" returns nothing useful.
---
## TL;DR
Conversations **are** being persisted — `auto_persist` writes every turn as a
timestamped `Conversation`/`Episodic` node. The failure is **retrieval**, not
storage. Two gaps:
1. **No recency-ordered retrieval.** There is no way to ask "give me my last N
conversation turns by time." Search is keyword-ranked only.
2. **Lexical-only search.** `search_memory``engram_search_json` is BM25/lexical.
A semantic/thematic query ("themes across recent conversations") doesn't share
keywords with the actual topic content, so it misses.
The model literally tried to express the missing capability in the fake tool call
it hallucinated: `"recency_weight": 0.8`, `"sort_by": "recency"`,
`node_type: "ConversationTurn"`. It wanted a recency-windowed conversation fetch
that doesn't exist.
## What exists today (verified)
- `auto_persist(req, resp)` (chat.el): after each non-agentic turn, stores
`{"q","a","created_at","source":"chat","label":"chat:<ts>"}` as
`engram_node_full(... "Conversation" ... "Episodic" ...)`, tags
`["Conversation","chat","timestamped"]`.
- `conv_history_persist` (chat.el): a **single overwriting** `conv:history`
Episodic node holding the rolling JSON history (continuity across restarts) —
not per-turn, not individually searchable.
- Live engram (founder instance): **5,113 nodes, 59 conversation nodes** — a mix
of `chat:<ts>`, several `conv:history` copies, and older `Q:/A:` nodes.
- Retrieval surface for the agentic loop: `search_memory`, `recall`,
`neuron_search_knowledge`, `neuron_recall` — all **query-keyword** based.
None is "most recent N by time," none is embedding/semantic.
## The gap, precisely
| User intent | Needs | Have today |
|---|---|---|
| "summarize my recent conversations" | last-N-by-time fetch | ✗ (keyword only) |
| "what did we discuss about X" | semantic match on topic | ~ (lexical only; misses paraphrase) |
| "themes across everything" | semantic cluster over corpus | ✗ |
`auto_persist` only fires on the **non-agentic** path (`handle_chat`). Worth
confirming the **agentic** path (`handle_chat_agentic`) persists turns too — if
not, agentic conversations never get stored, a second (smaller) gap.
## Proposal
Three layers, smallest-first. (1) alone fixes the headline use case.
### 1. Recency-windowed conversation retrieval (the high-value, low-cost win)
A runtime/engram primitive + an agentic tool:
- **Engram**: `engram_recent_by_type(node_type, limit, since_ts?)` → newest-first
by `created_at`. (Conversation nodes already carry `created_at`.)
- **Agentic tool**: `recent_conversations(limit=20, since?)`
`[{q,a,created_at}, …]`, newest first. Exposed in `agentic_tools_all`.
- **System-prompt hint**: for "recent / lately / this week / summarize our
conversations," prefer `recent_conversations` over `search_memory`.
This directly answers "summarize my recent conversations" — fetch last N, hand
the model the actual turns, let it cluster themes. No embeddings required.
### 2. Stable per-session threading
Today each turn is an independent `chat:<ts>` node; there's no session grouping.
Add `session_id` + a monotonic turn index to the persisted content (the UI already
sends `session_id`). Enables "summarize *this* conversation" and per-session recall,
and lets retrieval return coherent threads instead of loose turns.
### 3. Semantic retrieval (the real fix for thematic queries)
Lexical BM25 can't do "themes." Options, in order of effort:
- **a.** Embeddings on Conversation nodes + a vector search tool
(`semantic_search`). Biggest lift; also fixes knowledge recall broadly.
- **b.** Interim: a two-pass "map-reduce" — `recent_conversations` to pull the
window, then let the model cluster. Cheap, ships with (1), no infra.
Recommend **(1) + (2) now, (3b) as the interim thematic answer, (3a) as the
roadmap item** once embeddings land (this dovetails with the GraphRAG/embedding
work already noted in memory: substring 1.7% P@5 vs BM25 55% vs graph 21.7%).
## Open questions for Will
1. ~~Does the agentic path persist turns?~~ **Resolved: yes** — the dispatcher
calls `auto_persist` after both the agentic and non-agentic branches
(`routes.el` lines 156/298). Both paths store per-turn nodes.
2. `conv:history` is accumulating duplicate overwriting nodes (saw several in the
live engram) — intended, or should it truly overwrite/dedupe?
3. Is there appetite for the `engram_recent_by_type` primitive in the runtime, or
should recency be done in `.el` by scanning + sorting (fine at 59 nodes, weak
at scale)?
4. Embeddings (3a): on the roadmap timeline, or defer and ship (1)+(2)+(3b)?
## Not in scope
Persistence itself (it works), and the separate **confabulation** fix (model
faking tool calls in Just-chat mode) — that's `neuron` PR #29.
+2 -8
View File
@@ -46,10 +46,7 @@ fn mem_consolidate() -> String {
}
fn mem_save(path: String) -> Void {
let save_result: String = engram_save(path)
if str_eq(save_result, "") {
println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete")
}
engram_save(path)
}
fn mem_load(path: String) -> Void {
@@ -79,14 +76,11 @@ fn mem_boot_count_inc() -> Int {
let next: Int = current + 1
let content: String = "soul:boot_count:" + int_to_str(next)
let tags: String = "[\"soul-meta\",\"boot-counter\"]"
let boot_node_id: String = engram_node_full(
let discard: String = engram_node_full(
content, "Memory", "soul:boot_count",
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Canonical", tags
)
if str_eq(boot_node_id, "") {
println("[memory] mem_boot_count_inc: engram write failed — boot counter node lost (count=" + int_to_str(next) + ")")
}
return next
}
+2 -10
View File
@@ -400,7 +400,6 @@ fn handle_api_log_state_event(body: String) -> String {
let id: String = engram_node_full(parts, "InternalStateEvent", "state-event:manual",
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
"Episodic", tags)
if !api_persisted(id) { return api_not_persisted(id) }
return "{\"ok\":true,\"id\":\"" + id + "\",\"boot\":\"" + boot + "\"}"
}
@@ -453,7 +452,6 @@ fn handle_api_tune_config(body: String) -> String {
let id: String = engram_node_full(content, "ConfigEntry", key,
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
"Canonical", tags)
if !api_persisted(id) { return api_not_persisted(id) }
return "{\"ok\":true,\"key\":\"" + key + "\",\"value\":\"" + value + "\",\"id\":\"" + id + "\"}"
}
@@ -653,23 +651,17 @@ fn handle_api_consolidate(body: String) -> String {
let summary: String = json_get(body, "summary")
let snap: String = state_get("soul_snapshot_path")
if !str_eq(snap, "") {
let save_result: String = engram_save(snap)
if str_eq(save_result, "") {
println("[api] consolidate: engram_save failed for " + snap + " — snapshot may be out of sync")
}
engram_save(snap)
}
if !str_eq(summary, "") {
let safe_summary: String = str_replace(summary, "\"", "'")
let tags: String = "[\"SessionSummary\",\"consolidate\"]"
let summary_id: String = engram_node_full(
let discard: String = engram_node_full(
"[session-summary] " + safe_summary,
"SessionSummary", "session:summary",
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
"Episodic", tags
)
if str_eq(summary_id, "") {
println("[api] consolidate: session summary engram write failed — summary node lost")
}
}
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
}
+2 -10
View File
@@ -212,13 +212,8 @@ fn seed_persona_from_env() -> Void {
let h: Map = {}
map_set(h, "Content-Type", "application/json")
let resp: String = http_post_with_headers(engram_url + "/api/nodes", body, h)
// Check for empty response (timeout/network error), explicit error, or missing id.
if str_eq(resp, "") {
println("[soul] persona HTTP write-back failed: empty response (timeout or network error) — in-memory only this session")
} else if str_contains(resp, "\"error\"") {
if str_contains(resp, "\"error\"") {
println("[soul] persona HTTP write-back failed (in-memory only this session): " + resp)
} else if !str_contains(resp, "\"id\"") {
println("[soul] persona HTTP write-back: unexpected response (no id field) — in-memory only this session: " + resp)
} else {
println("[soul] persona persisted to HTTP engram at " + engram_url)
}
@@ -251,14 +246,11 @@ fn emit_session_start_event() -> Void {
+ ",\"ts\":" + int_to_str(ts) + "}"
let tags: String = "[\"internal-state\",\"session-start\",\"InternalStateEvent\"]"
let session_event_id: String = engram_node_full(
let discard: String = engram_node_full(
payload, "InternalStateEvent", "session-start",
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Episodic", tags
)
if str_eq(session_event_id, "") {
println("[soul] emit_session_start_event: engram write failed — session-start event lost")
}
println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + ")")
}