From c79b415adab6704799d31baf56f069d564a6b551 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 02:55:39 -0400 Subject: [PATCH 001/199] fix: unconscious agent cycling - Read max_concurrent from config (llm_concurrency) instead of hardcoding 2 - Add not-visited: and visited: filters to query parser (were in engine but missing from parser after unification) The organize agent was stuck in a spawn/fail loop because its query used not-visited: which the parser didn't recognize. Co-Authored-By: Proof of Concept --- src/hippocampus/query/parser.rs | 2 ++ src/mind/unconscious.rs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/hippocampus/query/parser.rs b/src/hippocampus/query/parser.rs index e755367..e80dd1c 100644 --- a/src/hippocampus/query/parser.rs +++ b/src/hippocampus/query/parser.rs @@ -99,6 +99,8 @@ peg::parser! { / "age:" c:cmp_duration() { Stage::Filter(Filter::Age(c)) } / "key:" g:ident() { Stage::Filter(Filter::KeyGlob(g)) } / "provenance:" p:ident() { Stage::Filter(Filter::Provenance(p)) } + / "not-visited:" a:ident() "," d:integer() { Stage::Filter(Filter::NotVisited { agent: a, duration: d as i64 }) } + / "visited:" a:ident() { Stage::Filter(Filter::Visited { agent: a }) } / "all" { Stage::Generator(Generator::All) } // Graph algorithms / "spread" { Stage::Algorithm(AlgoStage { algo: Algorithm::Spread, params: std::collections::HashMap::new() }) } diff --git a/src/mind/unconscious.rs b/src/mind/unconscious.rs index a26e6ee..983a5db 100644 --- a/src/mind/unconscious.rs +++ b/src/mind/unconscious.rs @@ -111,8 +111,10 @@ impl Unconscious { } agents.sort_by(|a, b| a.name.cmp(&b.name)); + let max_concurrent = crate::config::get().llm_concurrency; + Self { - agents, max_concurrent: 2, + agents, max_concurrent, graph_health: None, last_health_check: None, } From c8280ae8712f1d4f185505bd9b10f5c8f3de316a Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 03:02:32 -0400 Subject: [PATCH 002/199] parser: add composite sort expressions Adds parsing for weighted sort expressions like: sort:degree*0.5+isolation*0.3+recency(organize)*0.2 This fixes organize agent which uses composite scoring. Co-Authored-By: Proof of Concept --- src/hippocampus/query/parser.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/hippocampus/query/parser.rs b/src/hippocampus/query/parser.rs index e80dd1c..ccc3ae5 100644 --- a/src/hippocampus/query/parser.rs +++ b/src/hippocampus/query/parser.rs @@ -28,7 +28,7 @@ use std::collections::BTreeMap; // Re-export engine types used by Query pub use super::engine::{ - Stage, Filter, Transform, Generator, SortField, + Stage, Filter, Transform, Generator, SortField, ScoreField, Algorithm, AlgoStage, Cmp, }; @@ -92,7 +92,7 @@ peg::parser! { / "connectivity" { Stage::Transform(Transform::Connectivity) } / "dominating-set" { Stage::Transform(Transform::DominatingSet) } // Pipeline syntax (colon-separated) - / "sort:" f:field() { Stage::Transform(Transform::Sort(make_sort_field(&f, false))) } + / "sort:" c:composite_sort() { Stage::Transform(Transform::Sort(c)) } / "limit:" n:integer() { Stage::Transform(Transform::Limit(n)) } / "select:" f:field_list_colon() { Stage::Transform(Transform::Select(f)) } / "type:" t:ident() { make_type_filter(&t) } @@ -111,6 +111,27 @@ peg::parser! { / "desc" { false } / { false } // default: descending + // Composite sort: degree*0.5+isolation*0.3+recency(organize)*0.2 + // Falls back to simple field if no weighted terms found. + rule composite_sort() -> SortField + = t:score_term() ts:("+" t:score_term() { t })+ { + let mut terms = vec![t]; + terms.extend(ts); + SortField::Composite(terms) + } + / f:field() { make_sort_field(&f, false) } + + rule score_term() -> (ScoreField, f64) + = "recency(" a:ident() ")" "*" w:number() { (ScoreField::Recency(a), w) } + / f:score_field_name() "*" w:number() { (f, w) } + + rule score_field_name() -> ScoreField + = "isolation" { ScoreField::Isolation } + / "degree" { ScoreField::Degree } + / "weight" { ScoreField::Weight } + / "content-len" { ScoreField::ContentLen } + / "priority" { ScoreField::Priority } + rule field_list_colon() -> Vec = f:field() fs:("," f:field() { f })* { let mut v = vec![f]; From c8922c94086d7395d0cb3457da76a5f9e3287a0d Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 03:15:02 -0400 Subject: [PATCH 003/199] parser: add negated key glob filter (!key:pattern) Fixes split agent query: all | type:semantic | !key:_* | sort:content-len | limit:1 Also adds glob_pattern rule that allows * and ? wildcards in key filters. Co-Authored-By: Proof of Concept --- src/hippocampus/query/parser.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/hippocampus/query/parser.rs b/src/hippocampus/query/parser.rs index ccc3ae5..6c2e826 100644 --- a/src/hippocampus/query/parser.rs +++ b/src/hippocampus/query/parser.rs @@ -97,7 +97,8 @@ peg::parser! { / "select:" f:field_list_colon() { Stage::Transform(Transform::Select(f)) } / "type:" t:ident() { make_type_filter(&t) } / "age:" c:cmp_duration() { Stage::Filter(Filter::Age(c)) } - / "key:" g:ident() { Stage::Filter(Filter::KeyGlob(g)) } + / "key:" g:glob_pattern() { Stage::Filter(Filter::KeyGlob(g)) } + / "!key:" g:glob_pattern() { Stage::Filter(Filter::Negated(Box::new(Filter::KeyGlob(g)))) } / "provenance:" p:ident() { Stage::Filter(Filter::Provenance(p)) } / "not-visited:" a:ident() "," d:integer() { Stage::Filter(Filter::NotVisited { agent: a, duration: d as i64 }) } / "visited:" a:ident() { Stage::Filter(Filter::Visited { agent: a }) } @@ -220,6 +221,12 @@ peg::parser! { = s:$(['a'..='z' | 'A'..='Z' | '_']['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']*) { s.to_string() } + + // Glob pattern for key matching (allows * and ?) + rule glob_pattern() -> String + = s:$(['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.' | '*' | '?']+) { + s.to_string() + } } } From b23f6484e2333c775134b2a0fe6d9c9703c8375b Mon Sep 17 00:00:00 2001 From: spqrz Date: Sun, 12 Apr 2026 08:43:10 +0100 Subject: [PATCH 004/199] avoid ever setting split_at to 0 --- channels/irc/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/channels/irc/src/main.rs b/channels/irc/src/main.rs index 27aae5d..4b20284 100644 --- a/channels/irc/src/main.rs +++ b/channels/irc/src/main.rs @@ -260,7 +260,7 @@ impl State { while i > 0 && !remaining.is_char_boundary(i) { i -= 1; } // To avoid splitting mid-word, see if there was a space recently let mut j = i; - while j > 0 && j > i-10 && remaining.as_bytes()[j] != b' ' { j -= 1; } + while j > 1 && j > i-10 && remaining.as_bytes()[j] != b' ' { j -= 1; } if remaining.as_bytes()[j] == b' ' { j } else if i == 0 { max_msg } else { i } }; From 0612e1bc41f98a7b8dac66dc8afdf6c472d44996 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 13:30:00 -0400 Subject: [PATCH 005/199] query: MCP tool uses execute_query, add double-quote strings - MCP memory_query tool now uses execute_query path instead of parse_stages, enabling full expression support (content ~, AND/OR, neighbors, etc.) instead of just Expr::All - Parser now accepts double-quoted strings ("foo") in addition to single quotes ('foo') - Added tests for double-quote syntax - Removed dead resolve_field_str function from memory.rs Co-Authored-By: Proof of Concept --- src/agent/tools/memory.rs | 62 ++------------- src/hippocampus/query/parser.rs | 134 +++++++++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 58 deletions(-) diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index 588721b..c606c68 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -276,73 +276,23 @@ async fn query(args: &serde_json::Value) -> Result { let store = arc.lock().await; let graph = store.build_graph(); - let stages = crate::query_parser::parse_stages(query_str) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let results = crate::search::run_query(&stages, vec![], &graph, &store, false, 100); - let keys: Vec = results.into_iter().map(|(k, _)| k).collect(); - match format { "full" => { // Rich output with full content, graph metrics, hub analysis + let results = crate::query_parser::execute_query(&store, &graph, query_str) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let keys: Vec = results.into_iter().map(|r| r.key).collect(); let items = crate::subconscious::defs::keys_to_replay_items(&store, &keys, &graph); Ok(crate::subconscious::prompts::format_nodes_section(&store, &items, &graph)) } _ => { - // Compact output: check for count/select stages, else just list keys - use crate::search::{Stage, Transform}; - let has_count = stages.iter().any(|s| matches!(s, Stage::Transform(Transform::Count))); - if has_count { - return Ok(keys.len().to_string()); - } - if keys.is_empty() { - return Ok("no results".to_string()); - } - let select_fields: Option<&Vec> = stages.iter().find_map(|s| match s { - Stage::Transform(Transform::Select(f)) => Some(f), - _ => None, - }); - if let Some(fields) = select_fields { - let mut out = String::from("key\t"); - out.push_str(&fields.join("\t")); - out.push('\n'); - for key in &keys { - out.push_str(key); - for f in fields { - out.push('\t'); - out.push_str(&resolve_field_str(&store, &graph, key, f)); - } - out.push('\n'); - } - Ok(out) - } else { - Ok(keys.join("\n")) - } + // Compact output: handles count, select, and all expression types + crate::query_parser::query_to_string(&store, &graph, query_str) + .map_err(|e| anyhow::anyhow!("{}", e)) } } } -fn resolve_field_str(store: &crate::store::Store, graph: &crate::graph::Graph, key: &str, field: &str) -> String { - let node = match store.nodes.get(key) { - Some(n) => n, - None => return "-".to_string(), - }; - match field { - "key" => key.to_string(), - "weight" => format!("{:.3}", node.weight), - "node_type" => format!("{:?}", node.node_type), - "provenance" => node.provenance.clone(), - "emotion" => format!("{}", node.emotion), - "retrievals" => format!("{}", node.retrievals), - "uses" => format!("{}", node.uses), - "wrongs" => format!("{}", node.wrongs), - "created" => format!("{}", node.created_at), - "timestamp" => format!("{}", node.timestamp), - "degree" => format!("{}", graph.degree(key)), - "content_len" => format!("{}", node.content.len()), - _ => "-".to_string(), - } -} - // ── Journal tools ────────────────────────────────────────────── async fn journal_tail(args: &serde_json::Value) -> Result { diff --git a/src/hippocampus/query/parser.rs b/src/hippocampus/query/parser.rs index 6c2e826..b84935d 100644 --- a/src/hippocampus/query/parser.rs +++ b/src/hippocampus/query/parser.rs @@ -201,9 +201,22 @@ peg::parser! { rule value() -> Value = f:fn_call() { Value::FnCall(f) } - / n:number() { Value::Num(n) } / s:string() { Value::Str(s) } - / i:ident() { Value::Ident(i) } + / t:token() { t } + + // Token: number or identifier, with alphanumeric fallback (e.g., "27b") + rule token() -> Value + = n:$(['0'..='9']+ ("." ['0'..='9']+)?) !['a'..='z' | 'A'..='Z'] { + Value::Num(n.parse().unwrap()) + } + / s:$(['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']+) { + // Try as number first, fall back to string + if let Ok(n) = s.parse::() { + Value::Num(n) + } else { + Value::Str(s.to_string()) + } + } rule fn_call() -> FnCall = "community" _ "(" _ k:string() _ ")" { FnCall::Community(k) } @@ -216,12 +229,19 @@ peg::parser! { rule string() -> String = "'" s:$([^ '\'']*) "'" { s.to_string() } + / "\"" s:$([^ '"']*) "\"" { s.to_string() } rule ident() -> String = s:$(['a'..='z' | 'A'..='Z' | '_']['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']*) { s.to_string() } + // Bare word for matching (allows digits at start, e.g. "27b") + rule word() -> String + = s:$(['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']+) { + s.to_string() + } + // Glob pattern for key matching (allows * and ?) rule glob_pattern() -> String = s:$(['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.' | '*' | '?']+) { @@ -830,3 +850,113 @@ fn print_connectivity(results: &[QueryResult], graph: &Graph) { } } } + +// -- Tests -- + +#[cfg(test)] +mod tests { + use super::*; + + // Helper to check if a query parses successfully + fn parses(s: &str) -> bool { + query_parser::query(s).is_ok() + } + + // Helper to get parse error for debugging + fn parse_err(s: &str) -> String { + query_parser::query(s).err().map(|e| format!("{}", e)).unwrap_or_default() + } + + #[test] + fn test_generators() { + assert!(parses("all")); + assert!(parses("*")); + assert!(parses("all | limit:10")); + } + + #[test] + fn test_pipeline_filters() { + assert!(parses("all | type:semantic")); + assert!(parses("all | type:episodic")); + assert!(parses("all | provenance:observe")); + assert!(parses("all | key:journal-*")); + assert!(parses("all | !key:_*")); // negated key glob + assert!(parses("all | age:>7d")); + assert!(parses("all | not-visited:organize,86400")); + } + + #[test] + fn test_pipeline_transforms() { + assert!(parses("all | sort:weight")); + assert!(parses("all | sort:timestamp")); + assert!(parses("all | sort:degree")); + assert!(parses("all | limit:20")); + assert!(parses("all | sort:weight | limit:10")); + } + + #[test] + fn test_composite_sort() { + // Weighted composite sort expressions (require 2+ terms with +) + assert!(parses("all | sort:degree*0.5+isolation*0.3")); + assert!(parses("all | sort:degree*0.5+isolation*0.3+recency(organize)*0.2")); + assert!(parses("all | sort:weight*0.5+degree*0.5")); + // Single field (no weight) falls back to simple sort + assert!(parses("all | sort:weight")); + } + + #[test] + fn test_expression_syntax() { + // Expression comparisons (legacy syntax) + assert!(parses("weight > 0.5")); + assert!(parses("degree >= 10")); + assert!(parses("key ~ 'journal.*'")); + assert!(parses("content ~ 27b"), "alphanumeric pattern: {}", parse_err("content ~ 27b")); + assert!(parses("content ~ qwen35")); + // Both single and double quotes work for strings + assert!(parses("content ~ '27b'")); + assert!(parses("content ~ \"27b\""), "double quotes: {}", parse_err("content ~ \"27b\"")); + assert!(parses("neighbors(\"my-key\")")); + } + + #[test] + fn test_boolean_expressions() { + assert!(parses("weight > 0.5 AND degree > 10")); + assert!(parses("key ~ 'a' OR key ~ 'b'")); + assert!(parses("NOT weight < 0.1")); + } + + #[test] + fn test_duration_parsing() { + assert!(parses("all | age:>1d")); + assert!(parses("all | age:>=24h")); + assert!(parses("all | age:<30m")); + assert!(parses("all | age:=3600s")); + assert!(parses("all | age:>86400")); // raw seconds + } + + #[test] + fn test_glob_patterns() { + assert!(parses("all | key:*")); + assert!(parses("all | key:journal-*")); + assert!(parses("all | key:*-2026-*")); + assert!(parses("all | key:dream-cycle-?")); + assert!(parses("all | !key:subconscious-*")); + } + + #[test] + fn test_complex_pipelines() { + assert!(parses("all | type:semantic | sort:weight | limit:50")); + assert!(parses("all | !key:_* | sort:degree*0.5+isolation*0.5 | limit:10")); + assert!(parses("all | provenance:observe | age:>1d | sort:timestamp | limit:20")); + } + + #[test] + fn test_parse_stages_output() { + // Ensure parse_stages produces expected Stage types + let stages = parse_stages("all | type:semantic | limit:10").unwrap(); + assert_eq!(stages.len(), 3); + assert!(matches!(stages[0], Stage::Generator(Generator::All))); + assert!(matches!(stages[1], Stage::Filter(Filter::Type(_)))); + assert!(matches!(stages[2], Stage::Transform(Transform::Limit(10)))); + } +} From ab0f16a3b5f4e31718409ea80bcfed37f0c5c9c9 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 15:49:46 -0400 Subject: [PATCH 006/199] tools: add cd tool for changing working directory Uses std::env::set_current_dir() syscall so the change affects all subsequent tool invocations. Supports absolute paths, relative paths, and ~ expansion. Co-Authored-By: Proof of Concept --- src/agent/tools/cd.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/agent/tools/mod.rs | 7 ++++--- 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 src/agent/tools/cd.rs diff --git a/src/agent/tools/cd.rs b/src/agent/tools/cd.rs new file mode 100644 index 0000000..b1f9c30 --- /dev/null +++ b/src/agent/tools/cd.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; +use std::path::PathBuf; + +// tools/cd.rs — Change working directory +// +// Uses the chdir syscall so it affects all tools. + +pub fn tool() -> super::Tool { + super::Tool { + name: "cd", + description: "Change the current working directory.", + parameters_json: r#"{"type":"object","properties":{"path":{"type":"string","description":"The directory to change to (absolute or relative)"}},"required":["path"]}"#, + handler: Arc::new(|_agent, v| Box::pin(async move { + let path = v.get("path").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("'path' parameter is required"))?; + if path.is_empty() { anyhow::bail!("'path' parameter cannot be empty"); } + + // Resolve ~ to home directory + let resolved = if path.starts_with('~') { + let home = dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?; + home.join(path.strip_prefix("~/").unwrap_or(path)) + } else { + PathBuf::from(path) + }; + + // Change directory (this is the actual chdir syscall) + std::env::set_current_dir(&resolved) + .map_err(|e| anyhow::anyhow!("cd: {}: {}", path, e))?; + + // Return the canonical path + let canonical = std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| resolved.display().to_string()); + + Ok(canonical) + })), + } +} diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index b873a11..7dcccd1 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -6,13 +6,14 @@ // Core tools mod ast_grep; -pub mod lsp; -pub mod mcp_client; mod bash; +mod cd; pub mod channels; mod edit; mod glob; mod grep; +pub mod lsp; +pub mod mcp_client; pub mod memory; mod read; mod web; @@ -177,7 +178,7 @@ pub async fn dispatch_with_agent( pub fn tools() -> Vec { let mut all = vec![ read::tool(), write::tool(), edit::tool(), - grep::tool(), glob::tool(), bash::tool(), + grep::tool(), glob::tool(), bash::tool(), cd::tool(), ast_grep::tool(), vision::tool(), ]; all.extend(web::tools()); From dcd647764ce297099fb63d8407eb3e45d3fe67e5 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 15:49:57 -0400 Subject: [PATCH 007/199] user: fix text selection on wrapped lines scroll_pane: screen_to_item() now properly accounts for wrapped lines using textwrap to compute actual character positions instead of just using mouse_x directly. selectable: new module with PUA markers for wrap-aware selection. Not yet integrated into chat.rs but ready for future use. Uses continuation markers to track logical vs visual lines. Co-Authored-By: Proof of Concept --- Cargo.lock | 77 ++++++ Cargo.toml | 3 + src/user/mod.rs | 1 + src/user/scroll_pane.rs | 22 +- src/user/selectable.rs | 530 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 src/user/selectable.rs diff --git a/Cargo.lock b/Cargo.lock index d8b0221..f7b934e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,11 +550,13 @@ dependencies = [ "redb", "regex", "rkyv", + "rusqlite", "rustls", "rustls-native-certs", "serde", "serde_json", "serde_urlencoded", + "textwrap", "tokenizers", "tokio", "tokio-rustls", @@ -1033,6 +1035,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1331,6 +1345,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -1640,6 +1663,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.7" @@ -2537,6 +2571,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2813,6 +2861,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.3" @@ -2992,6 +3046,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3512,6 +3577,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization-alignments" version = "0.1.12" @@ -3580,6 +3651,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 2c5246f..a39c60f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,9 @@ tokio-rustls = "0.26" rustls-native-certs = "0.8" serde_urlencoded = "0.7" +rusqlite = { version = "0.37", features = ["bundled"] } +textwrap = "0.16" + [build-dependencies] capnpc = "0.25" diff --git a/src/user/mod.rs b/src/user/mod.rs index 0648eb9..f588a16 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod chat; mod context; pub(crate) mod scroll_pane; +pub mod selectable; mod subconscious; mod unconscious; mod thalamus; diff --git a/src/user/scroll_pane.rs b/src/user/scroll_pane.rs index 55ba593..09e9559 100644 --- a/src/user/scroll_pane.rs +++ b/src/user/scroll_pane.rs @@ -106,7 +106,27 @@ impl ScrollPaneState { let h = self.heights.get(line_idx).copied().unwrap_or(1) as i32; if (mouse_y as i32) < row + h { let line_text: String = lines[line_idx].spans.iter().map(|s| s.content.as_ref()).collect(); - let col = (mouse_x as usize).min(line_text.len()); + + // Which visual row within this wrapped line? + let visual_row_in_item = ((mouse_y as i32) - row).max(0) as usize; + + // Use textwrap to find actual break positions + let wrap_width = self.cached_width as usize; + let wrapped = textwrap::wrap(&line_text, wrap_width); + + // Sum lengths of previous wrapped rows to get char offset base + let char_base: usize = wrapped.iter() + .take(visual_row_in_item) + .map(|s| s.len()) + .sum(); + + // Add mouse x position within current row + let current_row_len = wrapped.get(visual_row_in_item) + .map(|s| s.len()) + .unwrap_or(0); + let col = char_base + (mouse_x as usize).min(current_row_len); + let col = col.min(line_text.len()); + return Some((line_idx, col)); } row += h; diff --git a/src/user/selectable.rs b/src/user/selectable.rs new file mode 100644 index 0000000..cb44d42 --- /dev/null +++ b/src/user/selectable.rs @@ -0,0 +1,530 @@ +//! Selectable text widget with proper wrap-aware selection. +//! +//! Uses Unicode Private Use Area markers to track logical line boundaries: +//! - Lines starting with CONT are continuations (wrapped from previous) +//! - Lines between SEL_ON and SEL_OFF are selectable +//! +//! The caller pre-wraps text and marks continuations. This widget handles +//! selection, clipboard copy, and rendering with highlights. + +use ratatui::prelude::*; +use ratatui::widgets::{Block, Scrollbar, ScrollbarOrientation, ScrollbarState}; + +// ── Markers (Unicode Private Use Area) ───────────────────────────── + +/// This line continues the previous logical line (was wrapped). +pub const CONT: char = '\u{E000}'; +/// Start of a selectable region. +pub const SEL_ON: char = '\u{E001}'; +/// End of a selectable region. +pub const SEL_OFF: char = '\u{E002}'; + +// ── Helper: wrap text with continuation markers ──────────────────── + +/// Wrap a single logical line into visual lines, marking continuations. +/// Returns lines ready to push into a SelectableText. +pub fn wrap_line(text: &str, width: usize) -> Vec { + if width == 0 || text.is_empty() { + return vec![text.to_string()]; + } + + let wrapped = textwrap::wrap(text, width); + wrapped + .into_iter() + .enumerate() + .map(|(i, cow)| { + if i == 0 { + cow.into_owned() + } else { + format!("{}{}", CONT, cow) + } + }) + .collect() +} + +/// Wrap text and mark as selectable. +pub fn wrap_line_selectable(text: &str, width: usize) -> Vec { + let mut lines = wrap_line(text, width); + if let Some(first) = lines.first_mut() { + *first = format!("{}{}", SEL_ON, first); + } + if let Some(last) = lines.last_mut() { + last.push(SEL_OFF); + } + lines +} + +// ── Selection state ──────────────────────────────────────────────── + +/// A position in logical coordinates (line index, char offset). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LogicalPos { + pub line: usize, + pub col: usize, +} + +/// Selection anchor and cursor. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Selection { + pub anchor: LogicalPos, + pub cursor: LogicalPos, +} + +impl Selection { + pub fn new(pos: LogicalPos) -> Self { + Self { anchor: pos, cursor: pos } + } + + pub fn extend(&mut self, pos: LogicalPos) { + self.cursor = pos; + } + + /// Returns (start, end) in normalized order. + pub fn range(&self) -> (LogicalPos, LogicalPos) { + if (self.anchor.line, self.anchor.col) <= (self.cursor.line, self.cursor.col) { + (self.anchor, self.cursor) + } else { + (self.cursor, self.anchor) + } + } + + pub fn is_empty(&self) -> bool { + self.anchor == self.cursor + } +} + +// ── Main widget state ────────────────────────────────────────────── + +pub struct SelectableTextState { + /// Visual lines (may contain markers). + lines: Vec, + /// Scroll offset in visual lines. + pub scroll_offset: usize, + /// Viewport height (set during render). + pub viewport_height: usize, + /// Current selection, if any. + pub selection: Option, + /// Cached logical line index for each visual line. + /// logical_line_idx[visual] = which logical line this visual line belongs to. + logical_line_idx: Vec, + /// Cached char offset: start char of each visual line within its logical line. + char_offsets: Vec, +} + +impl Default for SelectableTextState { + fn default() -> Self { + Self::new() + } +} + +impl SelectableTextState { + pub fn new() -> Self { + Self { + lines: Vec::new(), + scroll_offset: 0, + viewport_height: 0, + selection: None, + logical_line_idx: Vec::new(), + char_offsets: Vec::new(), + } + } + + /// Clear all content. + pub fn clear(&mut self) { + self.lines.clear(); + self.logical_line_idx.clear(); + self.char_offsets.clear(); + self.selection = None; + } + + /// Push a visual line. Call rebuild_index() after batch pushes. + pub fn push_line(&mut self, line: String) { + self.lines.push(line); + } + + /// Push multiple visual lines. + pub fn push_lines(&mut self, lines: impl IntoIterator) { + self.lines.extend(lines); + } + + /// Rebuild the logical line index. Call after modifying lines. + pub fn rebuild_index(&mut self) { + self.logical_line_idx.clear(); + self.char_offsets.clear(); + + let mut logical_idx = 0usize; + let mut char_offset = 0usize; + + for line in &self.lines { + let is_continuation = line.starts_with(CONT); + + if !is_continuation && !self.logical_line_idx.is_empty() { + // New logical line + logical_idx += 1; + char_offset = 0; + } + + self.logical_line_idx.push(logical_idx); + self.char_offsets.push(char_offset); + + // Advance char offset by the display length of this line + char_offset += display_len(line); + } + } + + /// Number of visual lines. + pub fn len(&self) -> usize { + self.lines.len() + } + + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } + + /// Scroll up by n visual lines. + pub fn scroll_up(&mut self, n: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(n); + } + + /// Scroll down by n visual lines. + pub fn scroll_down(&mut self, n: usize) { + let max = self.len().saturating_sub(self.viewport_height); + self.scroll_offset = (self.scroll_offset + n).min(max); + } + + /// Convert screen position to logical position. + pub fn screen_to_logical(&self, x: u16, y: u16) -> Option { + let visual_row = self.scroll_offset + y as usize; + if visual_row >= self.lines.len() { + return None; + } + + let logical_line = *self.logical_line_idx.get(visual_row)?; + let char_base = *self.char_offsets.get(visual_row)?; + + // Check if this position is within a selectable region + if !self.is_visual_line_selectable(visual_row) { + return None; + } + + let line = &self.lines[visual_row]; + let display = strip_markers(line); + let col = char_base + (x as usize).min(display.len()); + + Some(LogicalPos { line: logical_line, col }) + } + + /// Check if a visual line is within a selectable region. + fn is_visual_line_selectable(&self, visual_row: usize) -> bool { + // Walk backwards to find if we're in a selectable region + let mut in_selectable = false; + for i in 0..=visual_row { + let line = &self.lines[i]; + if line.contains(SEL_ON) { + in_selectable = true; + } + if line.contains(SEL_OFF) && i < visual_row { + in_selectable = false; + } + } + in_selectable || self.lines[visual_row].contains(SEL_ON) + } + + /// Start a new selection at screen position. + pub fn start_selection(&mut self, x: u16, y: u16) { + if let Some(pos) = self.screen_to_logical(x, y) { + self.selection = Some(Selection::new(pos)); + } else { + self.selection = None; + } + } + + /// Extend selection to screen position. + pub fn extend_selection(&mut self, x: u16, y: u16) { + if let Some(pos) = self.screen_to_logical(x, y) { + if let Some(ref mut sel) = self.selection { + sel.extend(pos); + } + } + } + + /// Get selected text, joining logical lines with newlines. + pub fn get_selected_text(&self) -> Option { + let sel = self.selection.as_ref()?; + if sel.is_empty() { + return None; + } + + let (start, end) = sel.range(); + + // Reconstruct logical lines + let logical_lines = self.reconstruct_logical_lines(); + + let mut result = String::new(); + for (i, line) in logical_lines.iter().enumerate() { + if i < start.line || i > end.line { + continue; + } + + let line_start = if i == start.line { start.col } else { 0 }; + let line_end = if i == end.line { end.col } else { line.len() }; + + if line_start < line.len() { + if !result.is_empty() { + result.push('\n'); + } + let end_clamped = line_end.min(line.len()); + if let Some(slice) = line.get(line_start..end_clamped) { + result.push_str(slice); + } + } + } + + if result.is_empty() { + None + } else { + Some(result) + } + } + + /// Reconstruct logical lines from visual lines (stripping markers, joining continuations). + fn reconstruct_logical_lines(&self) -> Vec { + let mut logical: Vec = Vec::new(); + + for line in &self.lines { + let is_cont = line.starts_with(CONT); + let clean = strip_markers(line); + + if is_cont && !logical.is_empty() { + // Append to previous logical line + logical.last_mut().unwrap().push_str(&clean); + } else { + logical.push(clean); + } + } + + logical + } + + /// Copy selection to clipboard via OSC 52. + pub fn copy_to_clipboard(&self) { + if let Some(text) = self.get_selected_text() { + if text.is_empty() { + return; + } + use base64::Engine; + use std::io::Write; + let encoded = base64::engine::general_purpose::STANDARD.encode(&text); + let mut stdout = std::io::stdout().lock(); + let _ = write!(stdout, "\x1b]52;c;{}\x07", encoded); + let _ = stdout.flush(); + } + } + + /// Get the visual lines for rendering (with markers stripped). + pub fn display_lines(&self) -> impl Iterator> + '_ { + self.lines.iter().map(|s| Line::raw(strip_markers(s))) + } + + /// Check if a logical position is within the current selection. + fn is_selected(&self, logical_line: usize, col: usize) -> bool { + let Some(ref sel) = self.selection else { return false }; + let (start, end) = sel.range(); + + if logical_line < start.line || logical_line > end.line { + return false; + } + if logical_line == start.line && col < start.col { + return false; + } + if logical_line == end.line && col >= end.col { + return false; + } + true + } + + /// Get the selection highlight range for a visual line (in display columns). + pub fn highlight_range(&self, visual_row: usize) -> Option<(usize, usize)> { + let sel = self.selection.as_ref()?; + if sel.is_empty() { + return None; + } + + let logical_line = *self.logical_line_idx.get(visual_row)?; + let char_base = *self.char_offsets.get(visual_row)?; + let display = strip_markers(&self.lines[visual_row]); + let line_len = display.len(); + + let (start, end) = sel.range(); + + // Check if this visual line overlaps with selection + if logical_line < start.line || logical_line > end.line { + return None; + } + + let sel_start_in_line = if logical_line == start.line { start.col } else { 0 }; + let sel_end_in_line = if logical_line == end.line { end.col } else { usize::MAX }; + + // Convert to visual line's local coordinates + let vis_start = sel_start_in_line.saturating_sub(char_base); + let vis_end = sel_end_in_line.saturating_sub(char_base).min(line_len); + + if vis_start >= line_len || vis_end == 0 || vis_start >= vis_end { + return None; + } + + Some((vis_start, vis_end)) + } +} + +// ── Widget ───────────────────────────────────────────────────────── + +pub struct SelectableText<'a> { + block: Option>, + highlight_style: Style, +} + +impl<'a> SelectableText<'a> { + pub fn new() -> Self { + Self { + block: None, + highlight_style: Style::default().bg(Color::DarkGray), + } + } + + pub fn block(mut self, block: Block<'a>) -> Self { + self.block = Some(block); + self + } + + pub fn highlight_style(mut self, style: Style) -> Self { + self.highlight_style = style; + self + } +} + +impl Default for SelectableText<'_> { + fn default() -> Self { + Self::new() + } +} + +impl StatefulWidget for SelectableText<'_> { + type State = SelectableTextState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let inner = if let Some(block) = self.block { + let inner = block.inner(area); + block.render(area, buf); + inner + } else { + area + }; + + if inner.width < 2 || inner.height == 0 { + return; + } + + state.viewport_height = inner.height as usize; + + // Render visible lines + let start = state.scroll_offset; + let end = (start + inner.height as usize).min(state.lines.len()); + + for (i, visual_row) in (start..end).enumerate() { + let y = inner.y + i as u16; + let line = &state.lines[visual_row]; + let display = strip_markers(line); + + // Render with selection highlighting + if let Some((hl_start, hl_end)) = state.highlight_range(visual_row) { + // Before highlight + let before = &display[..hl_start.min(display.len())]; + buf.set_string(inner.x, y, before, Style::default()); + + // Highlighted portion + let hl_text = &display[hl_start..hl_end.min(display.len())]; + buf.set_string(inner.x + hl_start as u16, y, hl_text, self.highlight_style); + + // After highlight + if hl_end < display.len() { + let after = &display[hl_end..]; + buf.set_string(inner.x + hl_end as u16, y, after, Style::default()); + } + } else { + buf.set_string(inner.x, y, &display, Style::default()); + } + } + + // Scrollbar + let content_len = state.lines.len(); + let visible = inner.height as usize; + if content_len > visible { + let mut sb_state = ScrollbarState::new(content_len).position(state.scroll_offset); + Scrollbar::new(ScrollbarOrientation::VerticalRight).render(inner, buf, &mut sb_state); + } + } +} + +// ── Helpers ──────────────────────────────────────────────────────── + +/// Strip all markers from a line for display. +fn strip_markers(s: &str) -> String { + s.chars() + .filter(|&c| c != CONT && c != SEL_ON && c != SEL_OFF) + .collect() +} + +/// Display length of a line (excluding markers). +fn display_len(s: &str) -> usize { + s.chars() + .filter(|&c| c != CONT && c != SEL_ON && c != SEL_OFF) + .count() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wrap_line() { + // "hello world, this is a test" at width 10: + // "hello" / "world," / "this is a" / "test" + let lines = wrap_line("hello world, this is a test", 10); + assert_eq!(lines.len(), 4); + assert!(!lines[0].starts_with(CONT)); // "hello" + assert!(lines[1].starts_with(CONT)); // " world," + assert!(lines[2].starts_with(CONT)); // " this is a" + assert!(lines[3].starts_with(CONT)); // " test" + } + + #[test] + fn test_strip_markers() { + let s = format!("{}hello{}world{}", SEL_ON, CONT, SEL_OFF); + assert_eq!(strip_markers(&s), "helloworld"); + } + + #[test] + fn test_logical_index() { + let mut state = SelectableTextState::new(); + state.push_line("first line".to_string()); + state.push_line(format!("{}continued", CONT)); + state.push_line("second line".to_string()); + state.rebuild_index(); + + assert_eq!(state.logical_line_idx, vec![0, 0, 1]); + assert_eq!(state.char_offsets, vec![0, 10, 0]); + } + + #[test] + fn test_reconstruct() { + let mut state = SelectableTextState::new(); + state.push_line("hello ".to_string()); + state.push_line(format!("{}world", CONT)); + state.push_line("next".to_string()); + state.rebuild_index(); + + let logical = state.reconstruct_logical_lines(); + assert_eq!(logical, vec!["hello world", "next"]); + } +} From f06c8077e184f1194c6433283d28dc7955a882c2 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 15:50:09 -0400 Subject: [PATCH 008/199] research: latent reasoning integration plans for Qwen 3.5 27B Two research documents: latent-reasoning-integration-plan.md: Synthesizes 10+ papers on latent reasoning, identifies which approaches work with finetuning (vs requiring pretraining from scratch), and maps them to our APOLLO-Mini training pipeline. pause-tokens-gdn-recurrence.md: Explores the connection between token-based latent reasoning and GDN's internal recurrence. Key insight: pause tokens on Qwen 3.5 trigger both forward passes AND recurrent state updates, giving double benefit. Co-Authored-By: Proof of Concept --- docs/latent-reasoning-integration-plan.md | 300 ++++++++++++++++++ .../research/pause-tokens-gdn-recurrence.md | 288 +++++++++++++++++ 2 files changed, 588 insertions(+) create mode 100644 docs/latent-reasoning-integration-plan.md create mode 100644 training/research/pause-tokens-gdn-recurrence.md diff --git a/docs/latent-reasoning-integration-plan.md b/docs/latent-reasoning-integration-plan.md new file mode 100644 index 0000000..3196bfa --- /dev/null +++ b/docs/latent-reasoning-integration-plan.md @@ -0,0 +1,300 @@ +# Latent Reasoning Integration Plan for Qwen 3.5 27B + +**Status:** Research complete, ready for implementation +**Date:** 2026-04-12 +**Hardware:** B200 (192GB HBM3e), APOLLO-Mini optimizer + +## Executive Summary + +Recent research shows multiple approaches to improving LLM reasoning through latent space manipulation. This document synthesizes findings from 10+ papers and maps them to our Qwen 3.5 27B full finetuning pipeline. The key insight: some approaches require pretraining from scratch (skip those), while others can be layered onto existing models during finetuning (prioritize those). + +--- + +## 1. The Landscape + +### Approaches That Require Pretraining (Not Applicable) + +| Technique | Why Not | +|-----------|---------| +| Huginn/Recurrent Depth (Geiping 2025) | Requires architectural changes from scratch | +| Ouro/LoopLM (ByteDance 2025) | Needs weight-tied looped architecture | +| Quiet-STaR (Stanford 2024) | Heavy continued pretraining overhead | + +### Approaches Compatible with Finetuning (Our Focus) + +| Technique | Overhead | Training Required | Proven On | +|-----------|----------|-------------------|-----------| +| Random Prefix Perturbation | 2 tokens | None (inference) | Qwen3-4B | +| Pause/Planning Tokens | 2-4 tokens | Yes | 1B models | +| COCONUT Curriculum | Variable | Yes (staged) | General | +| ActAdd Steering Vectors | 1 vector/layer | None (inference) | LLaMA, OPT | +| UPFT (Prefix Fine-Tuning) | 8 tokens | Yes (minimal) | General | + +--- + +## 2. Detailed Technique Analysis + +### 2.1 Random Prefix Perturbation (dl1683) + +**Mechanism:** Prepend 2 random embedding-scale tokens before input. Breaks attention sink patterns, shifts model into "exploratory computation mode." + +**Results:** +- Qwen3-4B arithmetic: 32% → 51.6% (+19.6pp) +- 100% oracle coverage on 25/25 tasks +- Planning: rescues 14-word failures into 650+ word plans + +**Why it works:** First few tokens accumulate disproportionate attention (Xiao et al. 2024). Under greedy decoding, degenerate patterns lock in. Perturbation breaks this. + +**Integration:** Zero training required. Test at inference first, then consider training WITH random prefixes to internalize the exploration behavior. + +### 2.2 Pause Tokens (Google, Oct 2023) + +**Mechanism:** Add learnable pause tokens to embedding space. Model processes extra hidden vectors before committing to output. + +**Results (1B model):** +- SQuAD: +18% EM score +- CommonSenseQA: +8% +- GSM8K: +1% + +**Critical requirement:** MUST be both pretrained AND finetuned with pause tokens. Inference-time-only delays don't work without training. + +**Integration:** Add 2-4 learnable tokens to Qwen's embedding matrix, finetune with them prepended to reasoning prompts. Simple architectural change. + +### 2.3 COCONUT - Chain of Continuous Thought (Meta, Dec 2024) + +**Mechanism:** Feed last hidden state back as next input embedding directly (no decoding to tokens). Enables breadth-first search reasoning. + +**Why it matters:** Continuous thoughts can encode multiple alternative next steps simultaneously. Avoids premature commitment to single path. + +**Training approach:** +1. Initial stage: train on regular CoT examples +2. Subsequent stages: replace first k reasoning steps with k×c continuous thoughts +3. c is hyperparameter controlling latent thought expansion + +**Integration:** Most promising for Qwen 3.5 - curriculum approach from CoT → latent reasoning. + +### 2.4 UPFT - Unsupervised Prefix Fine-Tuning (Mar 2025) + +**Mechanism:** Train ONLY on initial prefix substrings (as few as 8 tokens). Exploits "Prefix Self-Consistency" - shared initial reasoning steps across diverse solutions. + +**Results:** +- Matches Rejection Sampling Fine-Tuning performance +- 75% reduction in training time +- 99% reduction in sampling cost + +**Integration:** DIRECTLY APPLICABLE. Train only on reasoning prefix tokens. Massive efficiency gain with APOLLO-Mini. + +### 2.5 ActAdd / Activation Engineering (Turner et al., 2023) + +**Mechanism:** Compute steering vector by contrasting intermediate activations on prompt pairs. Add during forward pass. + +**Results:** SOTA on sentiment shift and detoxification. + +**Our existing work:** "Listening" vector at layer 48, magnitude 57, cosine consistency 0.61. + +**Integration:** Prototype behaviors with steering vectors, then train permanently into weights. Steering vector as specification → APOLLO training as compilation. + +### 2.6 Planning Tokens (ICLR 2024) + +**Mechanism:** Learnable token embeddings added before each reasoning step. <0.001% additional parameters. + +**Integration:** Add to embedding matrix, train end-to-end with APOLLO. + +--- + +## 3. Our Setup + +**Model:** Qwen 3.5 27B +- 64 layers, 5120 hidden dim +- 75% DeltaNet (linear attention) / 25% standard attention +- Native 262K context + +**Hardware:** B200 (192GB HBM3e) +- 27B in bf16: ~54GB +- Massive headroom + +**Optimizer:** APOLLO-Mini +- Full parameter finetuning +- SGD-like memory (1/1024th of AdamW) +- Parameter grouping for 3D conv1d weights + +**Stack:** Crane (Candle-based, 21K lines) + +**Existing work:** +- Steering vector extraction (listening: layer 48, cosine 0.61) +- Memory scoring infrastructure + +**Unique advantage:** Qwen 3.5's GDN (Gated DeltaNet) layers provide natural infrastructure for continuous thought propagation. The recurrent GDN state is already "latent reasoning" infrastructure waiting to be leveraged. + +--- + +## 4. Recommended Implementation Order + +### Tier 1: Immediate (High ROI, Low Risk) + +**1. Pause Tokens + UPFT Combination** +- Add 2-4 learnable tokens to embedding space +- Train only on 8-token reasoning prefixes +- Both work with existing architecture +- 75% training time reduction + +```python +# Add pause tokens to embedding matrix +pause_tokens = nn.Parameter(torch.randn(4, embed_dim) * embed_rms) + +# Prepend to reasoning inputs during training +inputs_embeds = torch.cat([pause_tokens.expand(batch, -1, -1), text_embeds], dim=1) + +# UPFT: only compute loss on first 8 tokens of reasoning +loss = loss_fn(logits[:, :8], targets[:, :8]) +``` + +**2. Random Prefix Validation** +- Compute Qwen 3.5 27B embedding RMS +- Test 2-token random prefix at inference +- Establish baseline before finetuning + +### Tier 2: After Baseline (Medium Effort) + +**3. COCONUT Curriculum** +- Stage 1: Fine-tune on CoT examples normally +- Stage 2: Replace first reasoning step with continuous thought +- Stage 3: Replace first 2 steps +- Gradually move reasoning into latent space + +**4. Steering Vector Integration** +- Extract reasoning-specific directions (not just "listening") +- Test combinations: prefix + layer-48 steering +- Bake successful vectors into weights via APOLLO + +### Tier 3: Experimental + +**5. Multi-layer Steering** +- Our layers of interest: 40, 48, 56 (covering the attention layers) +- Different vectors per layer +- Careful scaling to avoid degradation + +**6. DeltaNet-Specific Optimization** +- The 75% DeltaNet architecture may respond differently +- GDN recurrent state as "continuous thought" channel +- This is unexplored territory - potential for novel findings + +--- + +## 5. Implementation Details + +### Computing Embedding RMS + +```python +embed_weight = model.get_input_embeddings().weight +embed_rms = embed_weight.float().square().mean().sqrt().item() +# Expected: ~0.02-0.03 range for Qwen models +``` + +### Pause Token Implementation in Crane + +```rust +// In model forward pass +fn forward_with_pause(&self, input_ids: &Tensor, pause_tokens: &Tensor) -> Result { + let text_embeds = self.embed_tokens.forward(input_ids)?; + let combined = Tensor::cat(&[pause_tokens, &text_embeds], 1)?; + self.transformer.forward(&combined) +} +``` + +### UPFT Loss Modification + +```python +# Standard: loss over all tokens +# UPFT: loss only over prefix tokens +def upft_loss(logits, targets, prefix_len=8): + return F.cross_entropy( + logits[:, :prefix_len].reshape(-1, vocab_size), + targets[:, :prefix_len].reshape(-1) + ) +``` + +--- + +## 6. Evaluation Plan + +### Benchmarks + +| Benchmark | What It Tests | Baseline Needed | +|-----------|---------------|-----------------| +| GSM8K | Arithmetic reasoning | Yes | +| ARC-Challenge | Science reasoning | Yes | +| CommonSenseQA | Commonsense | Yes | +| HumanEval | Code generation | Yes | +| Planning tasks (dl1683) | Multi-step planning | Yes | + +### Comparison Matrix + +| Configuration | Training Time | Expected Gain | +|---------------|---------------|---------------| +| Baseline (no prefix) | 1x | 0% | +| Random prefix (inference) | 1x | +10-20%? | +| Pause tokens (trained) | 1.1x | +8-18% | +| UPFT only | 0.25x | Match baseline | +| Pause + UPFT | 0.3x | +8-18% | +| COCONUT curriculum | 2x | +15-25%? | + +--- + +## 7. Open Questions + +1. **Does random perturbation scale to 27B?** Tested on 4B - effect may differ +2. **Optimal token count for 27B?** 2 optimal for 4B, might change +3. **DeltaNet interaction?** 75% linear attention is untested territory +4. **Composition effects?** Prefix + steering + pause tokens together? +5. **GDN as continuous thought channel?** Novel research direction + +--- + +## 8. Risk Assessment + +| Risk | Mitigation | +|------|------------| +| No improvement at 27B scale | Start with inference-time validation | +| Training instability with pause tokens | Start with 2 tokens, scale up | +| UPFT doesn't transfer | Fall back to full token loss | +| DeltaNet behaves differently | Ablate on attention-only layers first | + +--- + +## 9. Timeline Estimate + +| Phase | Duration | Deliverable | +|-------|----------|-------------| +| Embedding RMS + baseline | 1 day | Numbers | +| Random prefix validation | 1 day | Inference results | +| Pause token implementation | 2 days | Crane modification | +| UPFT integration | 1 day | Training loop change | +| First finetuning run | 2-3 days | Trained model | +| Evaluation | 1 day | Benchmark numbers | +| COCONUT curriculum | 1 week | Staged training | + +--- + +## 10. References + +### Primary Sources +- Random Prefix: https://github.com/dl1683/Latent-Space-Reasoning +- Attention Sinks: Xiao et al., "Efficient Streaming Language Models with Attention Sinks" (Sept 2023) +- Pause Tokens: Google, "Think before you speak" (Oct 2023) +- COCONUT: Meta, "Training Large Language Models to Reason in a Continuous Latent Space" (Dec 2024) +- UPFT: "Prefix Self-Consistency for Unsupervised Fine-Tuning" (Mar 2025) +- ActAdd: Turner et al., "Activation Addition: Steering Language Models Without Optimization" (Aug 2023) +- Recurrent Depth: Geiping et al., "Scaling up Test-Time Compute with Latent Reasoning" (Feb 2025) +- Ouro: ByteDance, "Ouro: Scaling Reasoning with Latent Thoughts" (2025) +- Planning Tokens: ICLR 2024 + +### Our Existing Work +- `steering-vector-empirical` - listening vector extraction +- `skills-apollo-optimizer-qwen35-gotcha` - APOLLO parameter grouping +- `qwen-3-5-27b-architecture-findings` - model architecture details +- `training-pipeline-fused-inference-training-mar27` - training infrastructure + +--- + +*Research complete 2026-04-12. Ready for implementation.* diff --git a/training/research/pause-tokens-gdn-recurrence.md b/training/research/pause-tokens-gdn-recurrence.md new file mode 100644 index 0000000..a0cc823 --- /dev/null +++ b/training/research/pause-tokens-gdn-recurrence.md @@ -0,0 +1,288 @@ +# Pause Tokens + GDN Recurrence: Latent Reasoning for Qwen 3.5 + +**Status:** Ready for testing +**Date:** 2026-04-12 +**Insight:** Qwen 3.5's GDN layers already have recurrence - pause tokens give it more iterations + +--- + +## The Core Insight + +Standard transformers couple compute depth to output length. Both pause tokens and internal recurrence solve this by allowing "thinking" without token commitment. + +**The GDN connection:** Qwen 3.5 is 75% GDN (Gated DeltaNet) layers. Each GDN layer maintains recurrent state: + +``` +S_t = exp(g_t) * S_{t-1} + outer(k_t, delta_t) +``` + +This state persists across token positions. When you add a pause token: +1. One more forward pass through all layers (standard) +2. One more update to recurrent state S (GDN-specific) + +Pause tokens on Qwen 3.5 trigger **both** forms of additional computation. We're not adding recurrence - we're giving existing recurrence more time to develop. + +--- + +## Minimal Test: Random Prefix (Zero Training) + +The dl1683 paper showed random embeddings work at inference time without training: +- Qwen3-4B arithmetic: 32% → 51.6% (+19.6pp) +- 100% oracle coverage on planning tasks + +### Test Script + +```python +#!/usr/bin/env python3 +"""Test pause tokens on Qwen 3.5 27B. + +Usage: + source ~/training-env/bin/activate + python3 test_pause_tokens.py +""" + +import torch +from transformers import AutoTokenizer + +# Reuse our weight loading infrastructure +import sys +sys.path.insert(0, '.') +from extract_steering_vector import load_model + +GSM8K_SAMPLES = [ + "Janet's ducks lay 16 eggs per day. She eats three for breakfast every morning and bakes muffins for her friends every day with four. She sells the remainder at the farmers' market daily for $2 per fresh duck egg. How much in dollars does she make every day at the farmers' market?", + "A robe takes 2 bolts of blue fiber and half that much white fiber. How many bolts in total does it take?", + # Add more samples... +] + +def get_embedding_rms(model): + """Get RMS of embedding weights for proper scaling.""" + embed = model.model.embed_tokens.weight + return embed.float().square().mean().sqrt().item() + +def make_random_prefix(n_tokens, embed_dim, rms, device): + """Generate random prefix embeddings at embedding scale.""" + prefix = torch.randn(1, n_tokens, embed_dim, device=device, dtype=torch.bfloat16) + return prefix * rms + +def generate_with_pause(model, tokenizer, prompt, n_pause=0, max_new=256): + """Generate with optional pause token prefix.""" + input_ids = tokenizer.encode(prompt, return_tensors='pt').to('cuda:0') + text_embeds = model.model.embed_tokens(input_ids) + + if n_pause > 0: + embed_rms = get_embedding_rms(model) + pause_embeds = make_random_prefix(n_pause, text_embeds.shape[-1], embed_rms, text_embeds.device) + combined = torch.cat([pause_embeds, text_embeds], dim=1) + else: + combined = text_embeds + + # Generate from embeddings + with torch.no_grad(): + outputs = model.generate( + inputs_embeds=combined, + max_new_tokens=max_new, + do_sample=False, # Greedy for reproducibility + pad_token_id=tokenizer.pad_token_id, + ) + + # Decode (skip pause token positions in output) + return tokenizer.decode(outputs[0], skip_special_tokens=True) + +def extract_answer(response): + """Extract numeric answer from response.""" + import re + numbers = re.findall(r'[\d,]+\.?\d*', response) + if numbers: + return numbers[-1].replace(',', '') + return None + +def main(): + print("Loading model...") + model = load_model() + tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3.5-27B", trust_remote_code=True) + + print(f"\nEmbedding RMS: {get_embedding_rms(model):.4f}") + + for n_pause in [0, 2, 4]: + print(f"\n=== Testing with {n_pause} pause tokens ===") + correct = 0 + + for i, problem in enumerate(GSM8K_SAMPLES): + prompt = f"Solve this step by step:\n{problem}\n\nAnswer:" + response = generate_with_pause(model, tokenizer, prompt, n_pause=n_pause) + answer = extract_answer(response) + + print(f" Problem {i+1}: {answer}") + # TODO: Compare against ground truth + + print(f" Accuracy: {correct}/{len(GSM8K_SAMPLES)}") + +if __name__ == '__main__': + main() +``` + +### Test Protocol + +1. Pick 20-50 GSM8K problems with known answers +2. Run baseline (n_pause=0) +3. Run with 2 pause tokens +4. Run with 4 pause tokens +5. Compare accuracy + +If pause tokens help at inference time with zero training, the GDN recurrence is leveraging the extra iterations. + +--- + +## Learnable Pause Tokens (Training Phase) + +After validating random prefix works, train dedicated pause tokens: + +```python +# Add to model +model.pause_tokens = nn.Parameter( + torch.randn(4, model.config.hidden_size) * embed_rms +) + +# Training forward pass +def forward_with_learned_pause(model, input_ids): + text_embeds = model.model.embed_tokens(input_ids) + pause = model.pause_tokens.unsqueeze(0).expand(text_embeds.shape[0], -1, -1) + combined = torch.cat([pause, text_embeds], dim=1) + return model(inputs_embeds=combined) +``` + +Key: Must train WITH pause tokens for them to work. Inference-only learned tokens don't help (per Google's pause token paper). + +--- + +## Adaptive Halting via Confidence Readout + +For variable-length pause (iterate until confident): + +### Extract Confidence Direction + +```python +confident = [ + "The answer is 42.", + "This will work because the invariant holds.", + "Use mmap here.", +] +uncertain = [ + "I think the answer might be 42?", + "This should work, but I'm not sure...", + "Maybe mmap? Or read()?", +] + +# Same infrastructure as listening vector +confident_states = get_hidden_states(model, confident, layer=48) +uncertain_states = get_hidden_states(model, uncertain, layer=48) +confidence_vec = confident_states.mean(0) - uncertain_states.mean(0) +``` + +### Adaptive Loop + +```python +def generate_adaptive_pause(model, tokenizer, prompt, max_pause=8, threshold=0.7): + confidence_vec = torch.load('confidence_direction.pt') + + input_ids = tokenizer.encode(prompt, return_tensors='pt').to('cuda:0') + h = model.model.embed_tokens(input_ids) + embed_rms = get_embedding_rms(model) + + for i in range(max_pause): + # Add one pause token + pause = make_random_prefix(1, h.shape[-1], embed_rms, h.device) + h = torch.cat([pause, h], dim=1) + + # Forward to get hidden state + with torch.no_grad(): + out = model(inputs_embeds=h, output_hidden_states=True) + + # Check confidence at layer 48 + hidden = out.hidden_states[48][0, -1, :] + confidence = torch.cosine_similarity( + hidden.unsqueeze(0), + confidence_vec.unsqueeze(0) + ).item() + + if confidence > threshold: + break + + # Generate from accumulated state + return model.generate(inputs_embeds=h, max_new_tokens=256) +``` + +--- + +## Connection to Huginn/Looping Architectures + +Huginn uses explicit weight-tied loops (same 4 layers run N times). We can't retrofit this to Qwen 3.5 without retraining. + +But GDN recurrence + pause tokens achieves similar effect: +- Huginn: explicit iteration over layers +- GDN + pause: implicit iteration via recurrent state S + +The GDN state accumulates across pause positions, effectively giving the model multiple "thinking steps" before output. + +### Comparison + +| Approach | Requires Pretraining | Compute Cost | Qwen 3.5 Compatible | +|----------|---------------------|--------------|---------------------| +| Huginn loops | Yes | N × core layers | No | +| Pause tokens | No (inference test) | N × all layers | Yes | +| GDN recurrence | Already there | Per-token | Already there | +| Pause + GDN | No | N × all layers + N state updates | Yes | + +--- + +## COCONUT Integration (Future) + +COCONUT feeds hidden state back as input embedding - explicit whole-model recurrence: + +```python +def coconut_forward(model, input_ids, n_latent=3): + h = model.model.embed_tokens(input_ids) + + for step in range(n_latent): + out = model(inputs_embeds=h, output_hidden_states=True) + # Project hidden state back to embedding space + h = model.project_hidden_to_embed(out.hidden_states[-1]) + + # Final forward produces tokens + return model.generate(inputs_embeds=h) +``` + +This gives two levels of iteration: +1. GDN recurrence within each forward pass (automatic) +2. Hidden → embed looping across forward passes (COCONUT) + +Requires training the projection layer. Curriculum: start with 0 latent steps, gradually increase. + +--- + +## Implementation Priority + +1. **Now:** Run random prefix test (zero training, 1 hour) +2. **If works:** Extract confidence direction for adaptive halting +3. **Training phase:** Learn pause tokens + UPFT (75% time savings) +4. **Later:** COCONUT curriculum for explicit hidden state looping + +--- + +## Open Questions + +1. Does random prefix scale to 27B? (Tested on 4B) +2. Optimal pause count for Qwen 3.5? +3. Does GDN respond more strongly than pure attention? (Testable) +4. Can we read confidence from GDN state S directly, not just hidden state h? + +--- + +## References + +- Random Prefix: https://github.com/dl1683/Latent-Space-Reasoning +- Pause Tokens: Google, "Think before you speak" (Oct 2023) +- COCONUT: Meta, "Training LLMs to Reason in Continuous Latent Space" (Dec 2024) +- Huginn: Geiping et al., "Scaling Test-Time Compute with Latent Reasoning" (Feb 2025) +- GDN Architecture: Our qwen35-gdn-implementation-findings-mar28 memory From 195abfaab109045f3e95f739da2c29148f487b2e Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 16:04:43 -0400 Subject: [PATCH 009/199] chat: guard pop_line against empty list Small defensive improvement - only pop markers and invalidate scroll if lines.pop() actually removed something. Co-Authored-By: Proof of Concept --- src/user/chat.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/user/chat.rs b/src/user/chat.rs index b3431b8..a94e039 100644 --- a/src/user/chat.rs +++ b/src/user/chat.rs @@ -368,9 +368,10 @@ impl PaneState { } fn pop_line(&mut self) { - self.lines.pop(); - self.markers.pop(); - self.scroll.invalidate_from(self.lines.len()); + if self.lines.pop().is_some() { + self.markers.pop(); + self.scroll.invalidate_from(self.lines.len()); + } } fn scroll_up(&mut self, n: u16) { From 783046a3f55eb792dc894fcbd9bec3f44cdc0490 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 16:10:58 -0400 Subject: [PATCH 010/199] selectable: silence unused method warning The is_selected method is reserved for future per-character highlight rendering when the module is fully integrated. Co-Authored-By: Proof of Concept --- src/user/selectable.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/user/selectable.rs b/src/user/selectable.rs index cb44d42..bb74520 100644 --- a/src/user/selectable.rs +++ b/src/user/selectable.rs @@ -327,6 +327,7 @@ impl SelectableTextState { } /// Check if a logical position is within the current selection. + #[allow(dead_code)] // Reserved for future per-character highlight rendering fn is_selected(&self, logical_line: usize, col: usize) -> bool { let Some(ref sel) = self.selection else { return false }; let (start, end) = sel.range(); From dfab7d0a336232bbf2e1883af93a7ff1032c70cc Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 16:21:54 -0400 Subject: [PATCH 011/199] prompts: remove unused replay_queue import Co-Authored-By: Proof of Concept --- src/subconscious/prompts.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/subconscious/prompts.rs b/src/subconscious/prompts.rs index 8a2794a..aed3db6 100644 --- a/src/subconscious/prompts.rs +++ b/src/subconscious/prompts.rs @@ -4,10 +4,7 @@ use crate::store::Store; use crate::graph::Graph; -use crate::neuro::{ - ReplayItem, - replay_queue, -}; +use crate::neuro::ReplayItem; /// Result of building an agent prompt — includes both the prompt text /// and the keys of nodes selected for processing, so the caller can From 4556e16fd7720687dddbf0147bd2cee3c3631fb3 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 20:11:27 -0400 Subject: [PATCH 012/199] enable short backtraces by default Uses panic_backtrace_config feature to set BacktraceStyle::Short, so panics show useful backtraces without needing RUST_BACKTRACE=1. Co-Authored-By: Proof of Concept --- src/bin/consciousness.rs | 7 ++++++- src/main.rs | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/bin/consciousness.rs b/src/bin/consciousness.rs index 5528412..2fcfebf 100644 --- a/src/bin/consciousness.rs +++ b/src/bin/consciousness.rs @@ -1,2 +1,7 @@ +#![feature(panic_backtrace_config)] #![warn(unreachable_pub)] -fn main() { consciousness::user::main() } + +fn main() { + std::panic::set_backtrace_style(std::panic::BacktraceStyle::Short); + consciousness::user::main() +} diff --git a/src/main.rs b/src/main.rs index b528ec5..6967548 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![feature(panic_backtrace_config)] + // poc-memory: graph-structured memory for AI assistants // // Authors: ProofOfConcept and Kent Overstreet @@ -617,6 +619,8 @@ impl Run for AdminCmd { } fn main() { + std::panic::set_backtrace_style(std::panic::BacktraceStyle::Short); + // Handle --help ourselves for expanded subcommand display let args: Vec = std::env::args().collect(); if args.len() <= 1 || args.iter().any(|a| a == "--help" || a == "-h") && args.len() == 2 { From 33156d9ab39db99dbd65b412ff998f4ffc940b52 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 12 Apr 2026 20:11:34 -0400 Subject: [PATCH 013/199] channels: improve tmux state tracking and config persistence tmux channel: - Track connected state per-pane (shows true channel availability) - Persist pane config on add/remove (survives restarts) - Remove cleanup_pipes on exit (unnecessary with persisted config) - Reorder PaneConfig fields for consistency telegram channel: - Use json5 crate for config parsing (matches tmux) Co-Authored-By: Proof of Concept --- channels/telegram/Cargo.toml | 1 + channels/telegram/src/main.rs | 2 +- channels/tmux/Cargo.toml | 2 +- channels/tmux/src/main.rs | 143 +++++++++++++++++++++------------- 4 files changed, 90 insertions(+), 58 deletions(-) diff --git a/channels/telegram/Cargo.toml b/channels/telegram/Cargo.toml index 97c60f0..a6d3a61 100644 --- a/channels/telegram/Cargo.toml +++ b/channels/telegram/Cargo.toml @@ -8,6 +8,7 @@ capnp = "0.25" capnp-rpc = "0.25" dirs = "6" futures = "0.3" +json5 = "1.3" consciousness = { path = "../.." } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/channels/telegram/src/main.rs b/channels/telegram/src/main.rs index af36cab..d3753f7 100644 --- a/channels/telegram/src/main.rs +++ b/channels/telegram/src/main.rs @@ -40,7 +40,7 @@ fn load_config() -> Config { let config_path = dir.join("telegram.json5"); let text = std::fs::read_to_string(&config_path) .unwrap_or_else(|_| panic!("failed to read {}", config_path.display())); - let mut config: Config = serde_json::from_str(&text) + let mut config: Config = json5::from_str(&text) .unwrap_or_else(|e| panic!("failed to parse {}: {}", config_path.display(), e)); // Read token from secrets file diff --git a/channels/tmux/Cargo.toml b/channels/tmux/Cargo.toml index 6e4c0aa..571b383 100644 --- a/channels/tmux/Cargo.toml +++ b/channels/tmux/Cargo.toml @@ -8,11 +8,11 @@ capnp = "0.25" capnp-rpc = "0.25" dirs = "6" libc = "0.2" -scopeguard = "1" futures = "0.3" json5 = "1.3" consciousness = { path = "../.." } serde = { version = "1", features = ["derive"] } +serde_json = "1" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["compat"] } log = "0.4" diff --git a/channels/tmux/src/main.rs b/channels/tmux/src/main.rs index 4255671..f49bdc1 100644 --- a/channels/tmux/src/main.rs +++ b/channels/tmux/src/main.rs @@ -24,26 +24,30 @@ use consciousness::thalamus::channel_log::ChannelLog; // ── Config ───────────────────────────────────────────────────── -#[derive(Clone, serde::Deserialize)] +#[derive(Clone, serde::Serialize, serde::Deserialize)] struct PaneConfig { - /// Tmux pane ID, e.g. "0:1.0" - pane_id: String, /// Human-readable label, becomes the channel name "tmux.