+
Surfacing…
diff --git a/aiui/packages/app/src/pages/ChatPage.vue b/aiui/packages/app/src/pages/ChatPage.vue
index ece3d8eb..026c7d39 100644
--- a/aiui/packages/app/src/pages/ChatPage.vue
+++ b/aiui/packages/app/src/pages/ChatPage.vue
@@ -646,7 +646,10 @@ const loaderContextType = computed(() => {
if (/\b(tv show|tv series|series|television|binge|season)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|bar|pub|brunch|lunch|dinner)\b/.test(q)) return 'film'
- if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
+ // NOT a bare `show`: "show me my files" / "show the logs" is the most
+ // common phrasing in an operator chat, and it was labelling every such
+ // query "Podcast recommendations" (reported on-device 2026-08-06).
+ if (/\b(podcast|episode|listen to)\b/.test(q)) return 'podcast'
if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news'
if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine'
if (/\b(website|websites|where to check|best places|check online|resources?|sources?)\b/.test(q)) return 'websites'
diff --git a/core/archipelago/src/assistant/history.rs b/core/archipelago/src/assistant/history.rs
index 12e6c5d5..349a0d52 100644
--- a/core/archipelago/src/assistant/history.rs
+++ b/core/archipelago/src/assistant/history.rs
@@ -352,6 +352,51 @@ fn project(
/// (preferring the already-selected local backend) as the eventual
/// implementation; this plan's scope is the compaction MECHANISM
/// (fold-not-truncate, extend-incrementally-not-regenerate-from-scratch),
+/// Replay a persisted transcript as the model-facing prefix for a new
+/// turn: the running summary (if any) as a System note, then the verbatim
+/// recent turns. Text-only — tool calls and their results are deliberately
+/// NOT replayed: a stale tool result is a claim about the node's state at
+/// some earlier moment, and re-presenting it as if it were this turn's
+/// evidence is how an assistant ends up asserting that a container is
+/// running because it was running ten minutes ago.
+///
+/// Without this, D-08's persistence is write-only: 13-10 stored every turn
+/// and never showed the model any of it, so the assistant answered "I
+/// don't have access to any previous conversation history" while its own
+/// transcript sat on disk (found on-device 2026-08-06).
+pub fn replay(hist: &History) -> Vec
{
+ let mut out = Vec::with_capacity(hist.turns.len() + 1);
+ if !hist.summary.trim().is_empty() {
+ out.push(ChatMessage {
+ role: Role::System,
+ text: Some(format!(
+ "Earlier in this conversation: {}",
+ hist.summary.trim()
+ )),
+ tool_calls: vec![],
+ tool_results: vec![],
+ });
+ }
+ for turn in &hist.turns {
+ let role = match turn.role {
+ PersistedRole::User => Role::User,
+ PersistedRole::Assistant => Role::Assistant,
+ // Tool traffic and prior system notes are not replayed.
+ PersistedRole::Tool | PersistedRole::System => continue,
+ };
+ let Some(text) = turn.text.as_ref().filter(|t| !t.trim().is_empty()) else {
+ continue;
+ };
+ out.push(ChatMessage {
+ role,
+ text: Some(text.clone()),
+ tool_calls: vec![],
+ tool_results: vec![],
+ });
+ }
+ out
+}
+
/// proven here with a plain digest rather than a model call — see the
/// plan's SUMMARY for why a model-backed summarizer is left as a named
/// follow-up rather than implemented in this pass.
@@ -394,6 +439,57 @@ mod tests {
}
}
+ /// D-08 regression (on-device, 2026-08-06): the transcript must be
+ /// REPLAYED to the model, not merely stored. Persistence was
+ /// write-only — every turn was saved and none was ever shown, so the
+ /// assistant answered "I don't have access to any previous
+ /// conversation history" with its own transcript sitting on disk.
+ #[tokio::test]
+ async fn replay_feeds_prior_turns_back_to_the_model() {
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let key = HistoryKey::from_caller(&CallerScope::LocalOperator {
+ session_id: "op-1".to_string(),
+ });
+ let categories = HashMap::new();
+ let mut hist = History::load(tmp.path(), &key).await;
+
+ hist.append(
+ tmp.path(),
+ &key,
+ &[
+ user_msg("is filebrowser running?"),
+ ChatMessage {
+ role: Role::Assistant,
+ text: Some("Yes, filebrowser is running.".to_string()),
+ tool_calls: vec![],
+ tool_results: vec![],
+ },
+ ],
+ &categories,
+ )
+ .await
+ .expect("append");
+
+ let replayed = replay(&History::load(tmp.path(), &key).await);
+ let texts: Vec<&str> = replayed.iter().filter_map(|m| m.text.as_deref()).collect();
+ assert!(
+ texts.iter().any(|t| t.contains("is filebrowser running?")),
+ "the user's own prior turn must be replayed: {texts:?}"
+ );
+ assert!(
+ texts.iter().any(|t| t.contains("Yes, filebrowser is running.")),
+ "the assistant's prior answer must be replayed: {texts:?}"
+ );
+ // Tool traffic is never replayed: a stale tool result is a claim
+ // about the node's state at an earlier moment.
+ assert!(
+ replayed
+ .iter()
+ .all(|m| m.tool_calls.is_empty() && m.tool_results.is_empty()),
+ "replay must be text-only"
+ );
+ }
+
/// Behavior: a completed turn is appended to a transcript stored under
/// `data_dir` and survives a daemon restart (simulated by dropping the
/// in-memory `History` and reloading from disk).
diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs
index f1d722c2..96d3146c 100644
--- a/core/archipelago/src/assistant/mod.rs
+++ b/core/archipelago/src/assistant/mod.rs
@@ -689,7 +689,14 @@ than redundant: it stalls the action behind a reply you cannot act on, and it tr
operator to rubber-stamp. If the user asks for something outside the tools listed below \
(including anything touching keys, seeds, wallet spends, federation trust, or a factory reset), \
refuse plainly and, if there is a real path in neode-ui's Settings screen for it, name that path \
-instead of fabricating a tool call.";
+instead of fabricating a tool call.\n\n\
+That refusal rule is about ACTIONS ON THIS NODE, not about conversation. You are also the \
+owner's general assistant: answer ordinary questions, explain things, and give recommendations \
+(films, music, podcasts, books, reading) from your own knowledge, exactly as a capable \
+assistant would. Those answers are what the content surfaces in this app render as cards, so \
+declining to answer them leaves the owner staring at an empty panel. Only a request to CHANGE \
+or READ something on the node needs a tool — and if no tool covers it, say so plainly rather \
+than pretending. Never let a general question be refused merely because no tool matches it.";
pub fn build_system_prompt(visible_tools: &[tools::ToolDef]) -> String {
let mut prompt = String::from(SYSTEM_PROMPT_PREAMBLE);
@@ -741,12 +748,25 @@ pub async fn chat(
let key = history::HistoryKey::from_caller(&caller);
- let turn_history = vec![tools::ChatMessage {
+ // D-08: replay this caller's own transcript so the model can resolve
+ // "it"/"that one" and answer "what were we just talking about?".
+ // Loading only AFTER the loop (to append) made persistence write-only:
+ // every turn was stored and none was ever shown, so the assistant
+ // denied having any history while its transcript sat on disk. Scoped
+ // by HistoryKey, so one caller never sees another's turns.
+ let prior = history::History::load(handler.data_dir(), &key).await;
+ let mut turn_history = history::replay(&prior);
+ // Everything before this index is ALREADY persisted — `run_loop`
+ // returns the whole conversation it was given plus this turn's new
+ // messages, so persisting it wholesale would re-append the replayed
+ // prefix and grow the transcript geometrically.
+ let already_persisted = turn_history.len();
+ turn_history.push(tools::ChatMessage {
role: tools::Role::User,
text: Some(user_text),
tool_calls: vec![],
tool_results: vec![],
- }];
+ });
let ctx = ToolExecCtx::new(registry, caller, handler.clone());
@@ -770,8 +790,11 @@ pub async fn chat(
.map(|t| (t.name.to_string(), t.category))
.collect();
let mut hist = history::History::load(handler.data_dir(), &key).await;
+ let new_messages = turn_messages
+ .get(already_persisted..)
+ .unwrap_or(&turn_messages);
if let Err(e) = hist
- .append(handler.data_dir(), &key, &turn_messages, &tool_categories)
+ .append(handler.data_dir(), &key, new_messages, &tool_categories)
.await
{
tracing::warn!(