diff --git a/aiui/packages/app/src/components/content/ContextLoader.vue b/aiui/packages/app/src/components/content/ContextLoader.vue index f4cdf5f8..6f90738a 100644 --- a/aiui/packages/app/src/components/content/ContextLoader.vue +++ b/aiui/packages/app/src/components/content/ContextLoader.vue @@ -14,8 +14,11 @@ :class="isDark ? 'text-white/70' : 'text-gray-600'"> {{ contextLabel }}

-
-

+

+

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!(