From 96fd57a7c3e627ecb18806384092214ac2a28f7f Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Fri, 7 Aug 2026 23:23:43 -0400 Subject: [PATCH 01/19] feat(mcp): remote MCP over Streamable HTTP for web chats (ChatGPT + Claude.ai) - memmesh serve-mcp: authed Streamable-HTTP MCP endpoint (POST /mcp) wrapping the same 11 tools as stdio; bearer-token required; stateless - reusable memory_mcp::handle_message shared by stdio + HTTP transports - initialize echoes client's negotiated protocolVersion (2025-* clients) - separate from the loopback console (which stays unauthed/local-only) --- crates/cli/src/main.rs | 37 ++++++++++++ crates/mcp/src/lib.rs | 51 +++++++++++++---- crates/server/src/lib.rs | 13 +++++ crates/server/src/mcp_http.rs | 104 ++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 crates/server/src/mcp_http.rs diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d2a5b6d..bdf5767 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -218,6 +218,21 @@ enum Cmd { no_open: bool, }, + /// Start a REMOTE MCP server over Streamable HTTP for web chats + /// (ChatGPT + Claude.ai custom connectors), which can't use a local + /// stdio server. Unlike `mcp` (stdio) and `console` (loopback, no auth), + /// this is meant to be exposed publicly via a tunnel, so it REQUIRES a + /// bearer token. Pass --token or set MEMMESH_MCP_TOKEN; if neither is + /// set, a random token is generated and printed. + ServeMcp { + /// Address to bind. Default 127.0.0.1:7899 (put a tunnel in front). + #[arg(long, default_value = "127.0.0.1:7899")] + http: String, + /// Bearer token clients must send. Auto-generated if omitted. + #[arg(long, env = "MEMMESH_MCP_TOKEN")] + token: Option, + }, + /// Manage the agent teaching skill (markdown that tells the AI when /// and how to use the memory tools). Skill { @@ -788,6 +803,28 @@ async fn run(store: Arc, cli: Cli, license: License) -> Result<() memory_server::serve_http(store, &http).await?; } + Cmd::ServeMcp { http, token } => { + let token = token.unwrap_or_else(|| { + uuid::Uuid::new_v4().to_string().replace('-', "") + }); + let url = format!("http://{http}/mcp"); + println!(); + println!(" MemMesh remote MCP (Streamable HTTP)"); + println!(" ────────────────────────────────────"); + println!(" engine : {}", cli.db); + println!(" url : {url}"); + println!(" token : {token}"); + println!(); + println!(" Expose it publicly with a tunnel, e.g.:"); + println!(" cloudflared tunnel --url http://{http}"); + println!(" then register the resulting https URL + '/mcp' as a custom"); + println!(" connector in ChatGPT / Claude.ai, with header:"); + println!(" Authorization: Bearer {token}"); + println!(); + tracing::info!(db = %cli.db, addr = %http, "starting remote MCP server"); + memory_server::serve_mcp_http(store, license, &http, token).await?; + } + Cmd::Sync { skip_token_check } => { let cfg = memory_core::config::Config::load_or_default(); let sync_cfg = cfg.sync.clone().ok_or_else(|| { diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 27c8712..b562595 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -123,6 +123,22 @@ pub async fn run_stdio(storage: Arc, license: License) -> Result< Ok(()) } +/// Handle a single JSON-RPC message (raw string) and return the serialized +/// response value, or `None` for notifications (which get no response). Shared +/// by the stdio loop and the Streamable-HTTP transport in `memory-server`. +pub async fn handle_message( + message: &str, + storage: &S, + license: &License, +) -> Option { + handle_line(message, storage, license).await.map(|resp| { + serde_json::to_value(resp).unwrap_or_else(|e| { + json!({ "jsonrpc": "2.0", "id": null, + "error": { "code": -32603, "message": e.to_string() } }) + }) + }) +} + // Both early-return blocks below check id-presence and need readable control // flow with side effects (tracing). The clippy `?` rewrite hides intent. #[allow(clippy::question_mark)] @@ -165,17 +181,30 @@ async fn handle_line( } let response = match req.method.as_str() { - "initialize" => JsonRpcResponse::ok( - req.id, - json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { "tools": {} }, - "serverInfo": { - "name": SERVER_NAME, - "version": SERVER_VERSION, - } - }), - ), + "initialize" => { + // Echo the client's requested protocol version when present. stdio + // clients (Claude Code) send 2024-11-05; Streamable-HTTP clients + // (ChatGPT / Claude.ai connectors) negotiate newer revisions + // (2025-03-26 / 2025-06-18). Our tool surface is compatible across + // all of them, so echoing avoids a version-mismatch rejection. + let negotiated = req + .params + .get("protocolVersion") + .and_then(|v| v.as_str()) + .unwrap_or(PROTOCOL_VERSION) + .to_string(); + JsonRpcResponse::ok( + req.id, + json!({ + "protocolVersion": negotiated, + "capabilities": { "tools": {} }, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION, + } + }), + ) + } "tools/list" => JsonRpcResponse::ok(req.id, json!({ "tools": tool_definitions() })), diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 5178b24..49e052b 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -12,12 +12,25 @@ //! - gRPC server lands in v1.1 alongside the sync engine. mod http; +mod mcp_http; use anyhow::Result; use memory_license::License; use memory_storage::Storage; use std::sync::Arc; +/// Run the remote MCP server over Streamable HTTP (for ChatGPT / Claude.ai +/// custom connectors). Requires a bearer `token` on every request; meant to +/// be exposed publicly via a tunnel. Binds `addr` and blocks until cancelled. +pub async fn serve_mcp_http( + storage: Arc, + license: License, + addr: &str, + token: String, +) -> Result<()> { + mcp_http::serve_mcp_http(storage, license, addr, token).await +} + /// Run the MCP stdio server, dispatching tool calls to the given storage /// backend. `license` gates every write-path tool so callers can't grow /// memory past their plan's cap. Blocks until stdin closes. diff --git a/crates/server/src/mcp_http.rs b/crates/server/src/mcp_http.rs new file mode 100644 index 0000000..83fe898 --- /dev/null +++ b/crates/server/src/mcp_http.rs @@ -0,0 +1,104 @@ +// Copyright 2026 Thinkfleet AI, LLC Licensed under the Apache License, Version 2.0. + +//! Remote MCP server over **Streamable HTTP** — the transport ChatGPT and +//! Claude.ai custom connectors require (a local stdio server can't be +//! registered directly). This is deliberately separate from the loopback +//! console (`http.rs`, no auth): the remote MCP endpoint is meant to be +//! exposed publicly via a tunnel, so it **requires a bearer token** on every +//! request. +//! +//! Endpoints: +//! POST /mcp — one JSON-RPC message per request; returns the JSON-RPC +//! response as `application/json`, or `202` for +//! notifications. Requires `Authorization: Bearer `. +//! GET /mcp — 405 (we don't do server-initiated SSE streams). +//! GET /health — unauthed liveness for tunnel/uptime checks. +//! +//! Stateless: no `Mcp-Session-Id` is issued — every request is handled +//! independently, which the MCP spec permits and our tools allow. + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Json}, + routing::{get, post}, + Router, +}; +use memory_license::License; +use memory_storage::Storage; +use serde_json::json; +use std::sync::Arc; + +struct McpState { + storage: Arc, + license: Arc, + token: Arc, +} + +impl Clone for McpState { + fn clone(&self) -> Self { + Self { + storage: self.storage.clone(), + license: self.license.clone(), + token: self.token.clone(), + } + } +} + +fn authorized(headers: &HeaderMap, token: &str) -> bool { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .map(|h| h.strip_prefix("Bearer ").unwrap_or(h).trim() == token) + .unwrap_or(false) +} + +async fn mcp_post( + State(st): State>, + headers: HeaderMap, + body: String, +) -> axum::response::Response { + if !authorized(&headers, &st.token) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "unauthorized — send Authorization: Bearer " })), + ) + .into_response(); + } + match memory_mcp::handle_message(&body, st.storage.as_ref(), &st.license).await { + // Request → JSON-RPC response as application/json. + Some(value) => (StatusCode::OK, Json(value)).into_response(), + // Notification → no body, per JSON-RPC / MCP. + None => StatusCode::ACCEPTED.into_response(), + } +} + +/// Serve the remote MCP endpoint on `addr`, gated by `token`. Blocks until +/// cancelled. +pub async fn serve_mcp_http( + storage: Arc, + license: License, + addr: &str, + token: String, +) -> anyhow::Result<()> { + let state = McpState { + storage, + license: Arc::new(license), + token: Arc::new(token), + }; + let app = Router::new() + .route( + "/mcp", + post(mcp_post::).get(|| async { StatusCode::METHOD_NOT_ALLOWED }), + ) + .route( + "/health", + get(|| async { Json(json!({ "name": "memmesh-mcp", "status": "ok" })) }), + ) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(addr).await?; + tracing::info!(addr = %addr, "remote MCP (Streamable HTTP) server listening"); + axum::serve(listener, app).await?; + Ok(()) +} From 7cb739b7869efedd48bb8a3214857189f99be8cb Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Fri, 7 Aug 2026 23:24:33 -0400 Subject: [PATCH 02/19] docs(mcp): remote MCP runbook for ChatGPT + Claude.ai web connectors --- docs/remote-mcp.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/remote-mcp.md diff --git a/docs/remote-mcp.md b/docs/remote-mcp.md new file mode 100644 index 0000000..0f8aa29 --- /dev/null +++ b/docs/remote-mcp.md @@ -0,0 +1,79 @@ +# Remote MCP — wiring MemMesh into ChatGPT & Claude web chats + +Local tools (Claude Code, Cursor, Codex, Windsurf) speak **stdio MCP** — `memmesh install` +wires those up automatically. Web chats are different: **ChatGPT** and **claude.ai** +custom connectors require a **remote MCP server over Streamable HTTP at a public HTTPS +URL** with auth. A local stdio server cannot be registered directly. + +MemMesh ships this as `memmesh serve-mcp`. + +## 1. Start the remote MCP server + +```bash +# Pick a strong token (or let it auto-generate and print one). +export MEMMESH_MCP_TOKEN="$(openssl rand -hex 24)" +memmesh serve-mcp --http 127.0.0.1:7899 +``` + +It prints the URL (`http://127.0.0.1:7899/mcp`) and the bearer token. Every request must +send `Authorization: Bearer `; unauthenticated requests get `401`. `GET /health` +is unauthenticated for uptime checks. + +## 2. Expose it over public HTTPS (tunnel) + +The cloud services can't reach `localhost`, so put a tunnel in front. + +```bash +# ngrok +ngrok http 7899 +# → https://.ngrok-free.app (your MCP URL is that + /mcp) + +# …or cloudflared +cloudflared tunnel --url http://127.0.0.1:7899 +# → https://.trycloudflare.com +``` + +For anything beyond a quick test, use a **named/persistent tunnel** so the URL is stable, +and run `serve-mcp` + the tunnel as long-lived services. + +## 3. Register the connector + +**Claude.ai** (Pro/Max/Team/Enterprise): Settings → Connectors → *Add custom connector* → +paste `https:///mcp`. Under request headers add +`Authorization: Bearer `. Restart the chat; the `memory_*` tools appear. + +**ChatGPT** (Plus/Pro/Business/Enterprise/Edu; write tools require Business/Enterprise/Edu): +enable **Settings → Connectors → Advanced → Developer mode**, then *Create* a connector +with URL `https:///mcp`, auth type *Custom headers* → +`Authorization: Bearer `. + +## 4. Verify + +```bash +curl -s https:///mcp \ + -H "authorization: Bearer $MEMMESH_MCP_TOKEN" \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools | length' +# → 11 +``` + +## Security + +- **The token is the only thing protecting your memory.** Anyone with the URL + token can + read and write it. Use a long random token; rotate by restarting with a new one. +- Prefer a tunnel that lets you add IP allow-lists / access policies (Cloudflare Access, + ngrok OAuth) in front of the endpoint. +- The remote endpoint is intentionally separate from `memmesh console` (loopback, no auth) — + never expose the console. +- Full OAuth 2.1 (PKCE + Dynamic Client Registration) is the eventual "proper" auth; the + bearer-token header is supported by both ChatGPT and Claude today and is the quickest path. + +## How it fits + +| Transport | Command | Used by | +|---|---|---| +| stdio MCP | `memmesh mcp` (via `memmesh install`) | Claude Code, Cursor, Codex, Windsurf | +| Streamable HTTP MCP | `memmesh serve-mcp` + tunnel | ChatGPT, Claude.ai web | +| Loopback REST + UI | `memmesh console` | the local management console (never expose) | + +All three talk to the same `~/.memmesh/memory.db` and the same 11 memory tools. From 6834014bd15d106ddb397a0b66927df1c35f6336 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Fri, 7 Aug 2026 23:52:29 -0400 Subject: [PATCH 03/19] feat(install): wire SessionStart recall hook (not just capture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer promised 'session-start memory injection' but only wrote the UserPromptSubmit observe hook. Add a SessionStart hook that runs `search --format claude-context` so relevant memories are injected into context automatically at session start — completing the capture+recall loop without relying on the model choosing to call memory_search. Refactored the append-or-replace logic into a shared upsert_owned_hook helper. --- crates/cli/src/installer.rs | 80 +++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 71cfb3e..6a63ad9 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -305,36 +305,54 @@ fn install_claude_code_hook(settings_path: &Path, binary: &Path) -> Result<()> { bail!("{}::hooks is not an object", settings_path.display()); } let hooks_obj = hooks.as_object_mut().unwrap(); + let bin = binary.to_string_lossy(); - // Our hook entry — matcher "*" catches every prompt. Command runs - // synchronously (Claude Code waits for it), reads the prompt from - // stdin, and is heuristic-only so finishes in <50ms even on a cold - // start. We pipe stderr to /dev/null to keep Claude Code's output - // clean if extraction fails. - let our_hook = serde_json::json!({ + // 1. CAPTURE — UserPromptSubmit: pipe every prompt into the engine. + // matcher "*" catches every prompt; runs synchronously (Claude Code + // waits), heuristic-only so it finishes in <50ms even cold. stderr is + // dropped so a failure never dirties Claude Code's output. + let observe_hook = serde_json::json!({ "matcher": "*", - "hooks": [ - { - "type": "command", - "command": format!( - "{} observe --role user --json 2>/dev/null || true", - binary.to_string_lossy(), - ) - } - ] + "hooks": [ { + "type": "command", + "command": format!("{bin} observe --role user --json 2>/dev/null || true"), + } ] }); + upsert_owned_hook(hooks_obj, "UserPromptSubmit", observe_hook, "memmesh observe")?; + + // 2. RECALL — SessionStart: inject relevant memories into context at the + // start of each session. The hook's stdout is added to the session + // context, so `search --format claude-context` surfaces the memory + // block automatically (no dependency on the model choosing to search). + let recall_hook = serde_json::json!({ + "hooks": [ { + "type": "command", + "command": format!("{bin} search --format claude-context --limit 30 2>/dev/null || true"), + } ] + }); + upsert_owned_hook(hooks_obj, "SessionStart", recall_hook, "memmesh search")?; - // Append-or-replace logic: if there's already a hooks block we own - // (matcher "*" with a command containing "memmesh observe"), - // replace it; otherwise append to the array so other hooks survive. - let event = hooks_obj - .entry("UserPromptSubmit".to_string()) + let serialized = serde_json::to_string_pretty(&doc)?; + std::fs::write(settings_path, serialized)?; + Ok(()) +} + +/// Append-or-replace a hook we own under `event`, identified by `marker` +/// appearing in one of its command strings. Other vendors' hooks are left +/// untouched, and re-running install replaces our entry rather than +/// duplicating it. +fn upsert_owned_hook( + hooks_obj: &mut serde_json::Map, + event: &str, + our_hook: serde_json::Value, + marker: &str, +) -> Result<()> { + let ev = hooks_obj + .entry(event.to_string()) .or_insert_with(|| serde_json::json!([])); - let arr = event + let arr = ev .as_array_mut() - .ok_or_else(|| anyhow!("hooks.UserPromptSubmit is not an array"))?; - - let mut replaced = false; + .ok_or_else(|| anyhow!("hooks.{event} is not an array"))?; for entry in arr.iter_mut() { let is_ours = entry .get("hooks") @@ -343,22 +361,16 @@ fn install_claude_code_hook(settings_path: &Path, binary: &Path) -> Result<()> { hs.iter().any(|h| { h.get("command") .and_then(|c| c.as_str()) - .is_some_and(|s| s.contains("memmesh observe")) + .is_some_and(|s| s.contains(marker)) }) }) .unwrap_or(false); if is_ours { - *entry = our_hook.clone(); - replaced = true; - break; + *entry = our_hook; + return Ok(()); } } - if !replaced { - arr.push(our_hook); - } - - let serialized = serde_json::to_string_pretty(&doc)?; - std::fs::write(settings_path, serialized)?; + arr.push(our_hook); Ok(()) } From de4d6babc6f02e18f29525914a9ce35b56f172af Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Sat, 8 Aug 2026 00:03:14 -0400 Subject: [PATCH 04/19] fix(observe): extract prompt from AI-tool hook JSON envelope on stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's UserPromptSubmit (and similar hooks) pipe a JSON envelope ({"hook_event_name":...,"prompt":"..."}) to the hook command, not the raw prompt. `observe` read stdin as literal text, so it was observing the wrapper JSON and extracting nothing — the passive-capture hook silently no-op'd on every prompt. Detect the hook envelope and observe the inner .prompt instead. Plain-text stdin is unchanged (bare text won't match the hook markers). --- crates/cli/src/main.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index bdf5767..c64ee5f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -598,7 +598,12 @@ async fn run(store: Arc, cli: Cli, license: License) -> Result<() use std::io::Read; let mut buf = String::new(); std::io::stdin().read_to_string(&mut buf)?; - buf + // AI-tool hooks (Claude Code's UserPromptSubmit, etc.) deliver + // their input as a JSON envelope on stdin, not the raw prompt — + // e.g. {"hook_event_name":"UserPromptSubmit","prompt":"…"}. + // Observing the wrapper JSON extracts nothing, so pull the + // actual prompt out when we recognize that shape. + extract_hook_prompt(&buf).unwrap_or(buf) } }; let role_parsed = match role.as_str() { @@ -1369,6 +1374,27 @@ fn detect_git_project() -> Option { Some(last.to_string()) } +/// AI-tool hooks pipe a JSON envelope to stdin rather than the raw prompt. +/// Claude Code's `UserPromptSubmit` sends +/// `{"hook_event_name":"UserPromptSubmit","prompt":"…","session_id":…,…}`. +/// Detect that envelope and return the inner prompt so `observe` runs on the +/// user's text, not the wrapper. Returns `None` for plain text (observe as-is) +/// — a bare prompt won't parse as a JSON object with these hook markers. +fn extract_hook_prompt(raw: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(raw.trim()).ok()?; + let obj = v.as_object()?; + let is_hook_envelope = obj.contains_key("hook_event_name") + || obj.contains_key("session_id") + || obj.contains_key("transcript_path"); + if !is_hook_envelope { + return None; + } + obj.get("prompt") + .and_then(|p| p.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(|s| s.to_string()) +} + fn detect_os_user() -> String { std::env::var("USER") .or_else(|_| std::env::var("USERNAME")) From ebd2770452b499f3b0763a0c754b522836a55b32 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Sat, 8 Aug 2026 12:22:28 -0400 Subject: [PATCH 05/19] fix(memory): filter harness noise on capture + importance-rank session recall Two quality fixes that made memory *look* broken: - observe now drops harness chatter (task-notification / system-reminder / command wrappers) instead of saving it as 'memories' that bury real ones - session-start injection (claude-context) sorts by importance so facts/rules/ preferences lead, not recency-ordered conversational filler --- crates/cli/src/main.rs | 46 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c64ee5f..8698d8b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -606,6 +606,18 @@ async fn run(store: Arc, cli: Cli, license: License) -> Result<() extract_hook_prompt(&buf).unwrap_or(buf) } }; + // Drop harness/tool noise before it ever reaches the engine. + // task-notifications, system-reminders, and command wrappers are + // machine chatter, not things worth remembering — capturing them + // pollutes recall and buries the real memories. + if is_noise(&text) { + if json { + println!("{}", serde_json::json!({"saved": [], "candidateCount": 0, "skipped": "noise"})); + } else { + println!("observed: 0 saved (filtered harness noise)"); + } + return Ok(()); + } let role_parsed = match role.as_str() { "user" => Some(memory_core::extraction::ObserveRole::User), "assistant" => Some(memory_core::extraction::ObserveRole::Assistant), @@ -668,7 +680,7 @@ async fn run(store: Arc, cli: Cli, license: License) -> Result<() }; // Hybrid semantic + lexical + recency ranking. Falls back to the // lexical substring path automatically when embeddings are off. - let rows = memory_storage::search::search( + let mut rows = memory_storage::search::search( store.as_ref(), &filter, query.as_deref(), @@ -676,6 +688,18 @@ async fn run(store: Arc, cli: Cli, license: License) -> Result<() offset, ) .await?; + // For session-start injection (claude-context, typically no query) + // lead with the highest-value memories — facts / rules / preferences + // (importance ~8) before raw conversational observations (~3) — so + // the model sees signal first instead of recency-ordered chatter. + if format == "claude-context" { + rows.sort_by(|a, b| { + b.importance + .partial_cmp(&a.importance) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| b.created.cmp(&a.created)) + }); + } match format.as_str() { "json" => println!("{}", serde_json::to_string_pretty(&rows)?), "claude-context" => { @@ -1395,6 +1419,26 @@ fn extract_hook_prompt(raw: &str) -> Option { .map(|s| s.to_string()) } +/// True for machine/harness chatter that should never be remembered: +/// task-notification and system-reminder blocks injected by the AI tool, +/// command wrappers, and empty input. These arrive through the same hook +/// path as real prompts but carry no durable user intent. +fn is_noise(text: &str) -> bool { + let t = text.trim_start(); + if t.is_empty() { + return true; + } + const NOISE_PREFIXES: &[&str] = &[ + " String { std::env::var("USER") .or_else(|_| std::env::var("USERNAME")) From 8f0c4e2d3d150262f0115b291a03913c368a5146 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Sun, 9 Aug 2026 09:54:19 -0400 Subject: [PATCH 06/19] feat(extraction): detect ideas/aspirations as first-class 'idea' type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Softer forward-looking language (we should…, what if we…, might want to…, I'm thinking we…, it would be nice to…) was falling through to low-value raw observations. Add an Ideas rule block (lower priority than facts/decisions/ rules) so conversational ideas are captured as a listable 'idea' type. --- crates/core/src/extraction.rs | 70 +++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/core/src/extraction.rs b/crates/core/src/extraction.rs index f9c78e9..d356798 100644 --- a/crates/core/src/extraction.rs +++ b/crates/core/src/extraction.rs @@ -428,6 +428,76 @@ static RULES: Lazy> = Lazy::new(|| { Low, "explicit-remember", ), + // Ideas / aspirations — softer, forward-looking language the user + // muses about ("we should…", "what if we…", "might want to…"). Kept + // LOWER priority than facts/decisions/rules so those still win; this + // block upgrades what would otherwise be a low-value raw observation + // into a first-class, listable `idea`. Non-anchored `\b…` variants + // catch the cue mid-sentence (e.g. "…app we might want to do X"). + ( + r"^idea[:\-] ", + "idea", + Project, + 6.0, + Low, + "explicit-idea", + ), + ( + r"\b(one|another|quick|random|cool) idea\b", + "idea", + Project, + 6.0, + Low, + "explicit-idea", + ), + ( + r"^what if we ", + "idea", + Project, + 6.0, + Low, + "idea-what-if", + ), + ( + r"\bwe (might|may) want to ", + "idea", + Project, + 6.0, + Low, + "idea-aspiration", + ), + ( + r"\b(i'?m|we'?re) thinking (we|that|about|maybe) ", + "idea", + Project, + 6.0, + Low, + "idea-thinking", + ), + ( + r"\bwe (should|could|ought to) ", + "idea", + Project, + 5.0, + Low, + "idea-suggestion", + ), + ( + r"\bit would be (nice|good|great|cool|helpful|useful) (to|if) ", + "idea", + Project, + 5.0, + Low, + "idea-wish", + ), + ( + r"\b(eventually|someday|down the road|at some point) we ", + "idea", + Project, + 5.0, + Low, + "idea-future", + ), ]; rules From 336dc06ff934fef92ab2deeed83ede23f8fbf0fb Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Sun, 9 Aug 2026 09:57:27 -0400 Subject: [PATCH 07/19] =?UTF-8?q?feat(console):=20Ideas=20tab=20=E2=80=94?= =?UTF-8?q?=20browse/add/archive=20conversation-derived=20ideas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists type=idea memories (auto-captured from conversation via the new extraction rules), with manual add (observe 'idea: …') and archive-when-done. Completes the idea-tracking loop: passive capture + a dedicated surface. --- crates/server/src/ui/index.html | 52 ++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/server/src/ui/index.html b/crates/server/src/ui/index.html index 8e6a522..f48daaf 100644 --- a/crates/server/src/ui/index.html +++ b/crates/server/src/ui/index.html @@ -119,6 +119,7 @@ + @@ -193,6 +194,17 @@

   
 
+  
+  
+
   
   
 
+  
+  
+