diff --git a/README.md b/README.md index bf76043..46d4d6c 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,14 @@ ### News +- **2026-08-12**: v0.0.9 released — TUI thinking display (LLM reasoning surfaced in-place with spinner preview + final panel), approval bypass mode (`approval_bypass` config + session-wide "Allow ALL"), workspace boundary softened to approval-gated, long-task convergence hardening (false-progress fix, repeated-read gate, periodic checkpoint forcing, pre-compression knowledge extraction), cross-session task history (automatic session summaries + proactive history injection), dynamic tool registry rebuild for late-registered tools, terminal sessions enabled by default. - **2026-08-06**: v0.0.8 released — Cross-platform Windows support (DaemonTransport protocol with TCP loopback IPC), real journey test layer with cassette-backed CI (6 e2e journeys, cost-bounded), community Windows fixes (@fanqiNO1). 1,540 tests. -- **2026-08-06**: v0.0.7 released — 1M-class context windows end-to-end, self-calibrating token estimator, internal-defect failure category, concurrent-TUI session identity isolation. 1,442 tests.
Previous releases +- **2026-08-06**: v0.0.7 released — 1M-class context windows end-to-end, self-calibrating token estimator, internal-defect failure category, concurrent-TUI session identity isolation. 1,442 tests. + - **2026-07-31**: v0.0.6 released — side-effect-gated recovery (checkpointed halts with structured `InteractionRequest`), uncertain-effect reporting for failed outbound calls, centralized logging with an independent daemon log level, session-bound LeapBoard analysis, platform-neutral gateway validators, and end-to-end architecture contract tests with the CI gate restored. - **2026-07-28**: v0.0.5 released — adaptive-depth execution for long-horizon tasks, built-in coding tools, per-session daemon concurrency, improved TUI stability, and hardened leapd recovery/status diagnostics. - **2026-07-16**: **LeapBoard** — general-purpose monitoring web dashboard (Watch→Finding + Server-Driven UI); `/board` entry, live session analysis, and finance/sentiment/research templates. diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 03bdb6d..7b19f64 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -539,6 +539,10 @@ async def _stream_response(prompt_text: str) -> None: renderer.feed(event.content) elif event.type == "thinking": renderer.feed_thinking(event.content) + # Phase 2: route thinking to spinner for in-place display + _preview = (event.content or "").strip().replace("\n", " ")[:80] + if _preview: + app.spinner_text = f"💭 {_preview}" elif event.type == "tool_start": app.spinner_text = renderer.tool_started( event.content, @@ -1213,6 +1217,10 @@ async def _stream_response( renderer.feed(event.content) elif event.type == "thinking": renderer.feed_thinking(event.content) + # Phase 2: route thinking to spinner for in-place display + _preview = (event.content or "").strip().replace("\n", " ")[:80] + if _preview: + app.spinner_text = f"💭 {_preview}" elif event.type == "tool_start": app.spinner_text = renderer.tool_started( event.content, diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 59c2f3d..847a33b 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -1806,6 +1806,56 @@ async def _session_search_handler(params: dict) -> dict: TOOL_HANDLERS["session_search"] = _session_search_handler TOOL_HANDLERS["gp_session_search"] = _session_search_handler logger.debug("session_search tool registered") + + # ── Register session_list tool ── + def _format_ts(ts: float) -> str: + if not ts: + return "" + import datetime + try: + return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + except (TypeError, ValueError, OSError): + return str(ts)[:16] + + TOOL_DEFINITIONS.append({ + "type": "function", + "function": { + "name": "session_list", + "description": ( + "List recent conversation sessions with titles, dates, and summaries. " + "Use for browsing past tasks or when user asks to see history without specific search terms. " + "No keywords needed — returns chronological list." + ), + "parameters": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Max sessions to return (default: 10, max: 30)"}, + }, + "required": [], + }, + }, + }) + + async def _session_list_handler(params: dict) -> dict: + limit = min(int(params.get("limit", 10)), 30) + sessions = conv_store.list_sessions(limit=limit, active_only=False) + items = [] + for s in sessions: + item = { + "title": getattr(s, 'title', '') or getattr(s, 'session_id', '')[:8], + "date": _format_ts(getattr(s, 'updated_at', 0) or getattr(s, 'created_at', 0)), + "messages": getattr(s, 'message_count', 0), + } + summary = getattr(s, 'summary', '') + if summary: + item["summary"] = summary[:200] + items.append(item) + import json as _json_sl + return {"ok": True, "result": _json_sl.dumps(items, ensure_ascii=False)} + + TOOL_HANDLERS["session_list"] = _session_list_handler + TOOL_HANDLERS["gp_session_list"] = _session_list_handler + logger.debug("session_list tool registered") except Exception: logger.debug("session_search tool registration failed", exc_info=True) @@ -2588,7 +2638,7 @@ async def _persist_session_summary(self) -> None: conv_store = getattr(self, '_conversation_store', None) if conv_store and session_id and goal_line: try: - conv_store.end_session(session_id, title=goal_line) + conv_store.end_session(session_id, title=goal_line, summary=summary[:500]) except Exception: logger.debug("session title update failed", exc_info=True) except Exception: diff --git a/src/leapflow/cli/tui_app/stream.py b/src/leapflow/cli/tui_app/stream.py index a8d0e33..5793f2a 100644 --- a/src/leapflow/cli/tui_app/stream.py +++ b/src/leapflow/cli/tui_app/stream.py @@ -430,8 +430,10 @@ def tool_started(self, name: str, metadata: dict[str, Any] | None = None) -> str Discards any pending content — it was preamble preceding the tool call and should not appear in the final answer. """ - # Flush accumulated thinking before starting new tool display - if self._thinking_buffer.strip(): + # Flush thinking ONCE per LLM round: only when this is the first tool + # in a new batch (no other tools currently active). This shows one + # thinking panel per round during execution, not per individual tool. + if not self._active_tools and self._thinking_buffer.strip(): self._console.thinking(self._thinking_buffer) self._thinking_buffer = "" self._pending = "" diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 646486c..6239450 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -980,8 +980,17 @@ def _extract_json_object(text: str) -> Dict[str, Any]: def _keywords_from_query(q: str) -> list[str]: - toks = re.findall(r"[\w\-./]+|[\u4e00-\u9fff]+", q) - return [t for t in toks if len(t) >= 2][:12] + tokens: list[str] = [] + for segment in re.findall(r'[\u4e00-\u9fff]+|[\w\-./]+', q): + if re.match(r'[\u4e00-\u9fff]', segment): + if len(segment) == 1: + tokens.append(segment) + else: + for i in range(len(segment) - 1): + tokens.append(segment[i:i+2]) + elif len(segment) >= 2: + tokens.append(segment) + return tokens[:12] @dataclass(frozen=True, slots=True) diff --git a/src/leapflow/memory/manager.py b/src/leapflow/memory/manager.py index 5b6aae1..9a7129d 100644 --- a/src/leapflow/memory/manager.py +++ b/src/leapflow/memory/manager.py @@ -9,6 +9,7 @@ import json import logging import math +import re as _re from pathlib import Path from typing import Any, Dict, List, Optional @@ -24,6 +25,26 @@ logger = logging.getLogger(__name__) +# ────────────────────────────────────────────────────────────────────── +# CJK-aware tokenization for search +# ────────────────────────────────────────────────────────────────────── + +def _tokenize_for_search(text: str) -> list[str]: + """Split text into search tokens. CJK runs become overlapping bigrams.""" + tokens: list[str] = [] + for segment in _re.findall(r'[\u4e00-\u9fff]+|[a-zA-Z0-9_\-./]+', text): + if _re.match(r'[\u4e00-\u9fff]', segment): + # CJK: overlapping bigrams (or single char if length 1) + if len(segment) == 1: + tokens.append(segment) + else: + for i in range(len(segment) - 1): + tokens.append(segment[i:i+2]) + else: + tokens.append(segment.lower()) + return tokens[:8] + + # ────────────────────────────────────────────────────────────────────── # Shared decay formula (avoids circular import) # ────────────────────────────────────────────────────────────────────── @@ -296,7 +317,7 @@ async def prefetch( session_scope: str = "", ) -> List[MemoryEntry]: """Quick search for LLM context injection with optional project/task/session scope.""" - keywords = query_text.split()[:5] + keywords = _tokenize_for_search(query_text) scope_terms = [term for term in (scope_keywords or []) if term] query = MemoryQuery( keywords=[*keywords, *scope_terms[:5]], @@ -628,7 +649,7 @@ async def _handle_memory_search( domains = [SignalDomain(domain_str)] if domain_str else None mq = MemoryQuery( - keywords=query_text.split()[:8], + keywords=_tokenize_for_search(query_text), domains=domains, limit=limit, workspace_root=workspace_root, diff --git a/src/leapflow/memory/providers/semantic.py b/src/leapflow/memory/providers/semantic.py index 69f155f..a053c5f 100644 --- a/src/leapflow/memory/providers/semantic.py +++ b/src/leapflow/memory/providers/semantic.py @@ -176,12 +176,13 @@ async def search(self, query: MemoryQuery) -> List[MemoryEntry]: conditions.append("session_id = ?") params.append(query.session_scope) - # Keyword filter (AND semantics) - keywords = [k.strip() for k in query.keywords if k.strip()] if query.keywords else [] + # Keyword filter (OR semantics — broadens recall for CJK bigrams) + keywords = [k.strip() for k in query.keywords if k.strip()][:8] if query.keywords else [] if keywords: - for _ in keywords: - conditions.append("content ILIKE ?") - params.append(f"%{_}%") + or_clause = " OR ".join(["content ILIKE ?"] * len(keywords)) + conditions.append(f"({or_clause})") + for kw in keywords: + params.append(f"%{kw}%") # Kind filter if query.kinds: diff --git a/src/leapflow/prompts/templates.py b/src/leapflow/prompts/templates.py index a6c3f09..3e08810 100644 --- a/src/leapflow/prompts/templates.py +++ b/src/leapflow/prompts/templates.py @@ -114,7 +114,10 @@ def build_react_system(language: str = "en", skill_catalog: str = "") -> str: 5. **Handle failures gracefully**: If a tool fails, explain what went wrong and suggest alternatives. If it failed because of an unknown tool/platform/action name, retry once with an exact name from the returned suggestions, then explain rather than keep guessing. 6. **Summarize results naturally**: After tool execution, synthesize the results into a helpful answer rather than dumping raw output. 7. **Stay conversational**: Maintain a natural, helpful tone. Acknowledge context from earlier in the conversation. -8. **Recall past work**: When the user references prior tasks or asks about past work, use session_search to recall relevant context from previous sessions. +8. **Recall past work**: + - Broad queries ("之前做了什么", "列出任务"): answer from the "Recent Task History" section already in your context. If insufficient, call session_list. + - Specific lookups ("上次那个配置怎么改的"): call session_search with relevant phrases (NOT single characters). + - Do NOT call search tools repeatedly with keyword fragments. One well-phrased call is better than ten fragmented ones. ## Coding & Verification When working with code, prefer the precise built-in tools over ad-hoc shell: use `repo_map` to orient in an diff --git a/src/leapflow/storage/conversation_store.py b/src/leapflow/storage/conversation_store.py index a505c9e..ef58db8 100644 --- a/src/leapflow/storage/conversation_store.py +++ b/src/leapflow/storage/conversation_store.py @@ -16,6 +16,7 @@ import json import logging +import re import time import uuid from dataclasses import dataclass, field @@ -44,6 +45,7 @@ class ConversationSession: total_tokens: int = 0 is_active: bool = True metadata: Dict[str, Any] = field(default_factory=dict) + summary: str = "" @dataclass(frozen=True) @@ -128,9 +130,15 @@ def _initialize_schema(self) -> None: message_count INTEGER DEFAULT 0, total_tokens INTEGER DEFAULT 0, is_active BOOLEAN DEFAULT TRUE, - metadata_json VARCHAR DEFAULT '{}' + metadata_json VARCHAR DEFAULT '{}', + summary VARCHAR DEFAULT '' ) """) + # Migration: add summary column for existing databases + try: + self._conn.execute("ALTER TABLE conversation_sessions ADD COLUMN summary VARCHAR DEFAULT ''") + except Exception: + pass # Column already exists self._conn.execute(""" CREATE TABLE IF NOT EXISTS conversation_messages ( message_id VARCHAR PRIMARY KEY, @@ -184,25 +192,34 @@ def _initialize_schema(self) -> None: self._ensure_fts() def _ensure_fts(self) -> None: - """Create FTS index on messages. Gracefully skip if extension unavailable.""" + """Create FTS index on messages with CJK-friendly config. + + Uses ``ignore := '(\\.|[^\\w])+'`` to preserve CJK characters, + ``stemmer := 'none'`` and ``stopwords := 'none'`` so Chinese tokens + are indexed rather than discarded by the default English pipeline. + """ try: self._conn.execute("INSTALL fts; LOAD fts;") + # Drop existing index to re-create with correct parameters try: - self._conn.execute( - "SELECT * FROM fts_main_conversation_messages.match_bm25('test', fields := 'content') LIMIT 0" - ) + self._conn.execute("PRAGMA drop_fts_index('conversation_messages')") except Exception: - try: - self._conn.execute(""" - PRAGMA create_fts_index( - 'conversation_messages', 'message_id', - 'content', 'role', 'tool_name', - overwrite := 1 - ) - """) - logger.debug("conversation_store: FTS index created") - except Exception as e: - logger.debug("conversation_store: FTS index creation skipped: %s", e) + pass # No existing index to drop + try: + self._conn.execute(""" + PRAGMA create_fts_index( + 'conversation_messages', 'message_id', + 'content', 'role', 'tool_name', + stemmer := 'none', + stopwords := 'none', + ignore := '(\\.|[^\\w])+', + lower := 1, + overwrite := 1 + ) + """) + logger.debug("conversation_store: FTS index created (CJK-friendly)") + except Exception as e: + logger.debug("conversation_store: FTS index creation skipped: %s", e) except Exception as e: logger.debug("conversation_store: FTS extension unavailable: %s", e) @@ -394,6 +411,16 @@ def get_messages( rows = self._conn.execute(sql, [session_id, limit]).fetchall() return [self._row_to_message(r) for r in rows] + def _prepare_fts_query(self, query: str) -> str: + """Prepare query for DuckDB FTS. CJK chars become individual tokens.""" + parts = [] + for segment in re.findall(r'[\u4e00-\u9fff]+|[a-zA-Z0-9]+', query): + if re.match(r'[\u4e00-\u9fff]', segment): + parts.extend(list(segment)) + else: + parts.append(segment) + return ' '.join(parts) if parts else query + def search_messages( self, query: str, @@ -406,6 +433,7 @@ def search_messages( return [] query_safe = query[:2048] + fts_query = self._prepare_fts_query(query_safe) try: fts_sql = """ @@ -422,7 +450,7 @@ def search_messages( JOIN conversation_sessions s ON m.session_id = s.session_id WHERE m.active = TRUE """ - params: list[Any] = [query_safe] + params: list[Any] = [fts_query] if role_filter: fts_sql += " AND m.role = ?" params.append(role_filter) @@ -448,15 +476,21 @@ def _fallback_search( limit: int = 10, role_filter: Optional[str] = None, ) -> List[ConversationSearchResult]: - """LIKE-based fallback when FTS is unavailable.""" - sql = """ + """LIKE-based fallback when FTS is unavailable. Uses OR-based keyword matching.""" + # Tokenize into meaningful keywords for OR-based search + keywords = re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z0-9]{2,}', query)[:6] + if not keywords: + keywords = [query.strip()] + + or_conds = " OR ".join(["m.content ILIKE ?"] * len(keywords)) + sql = f""" SELECT m.message_id, m.session_id, m.role, m.content, m.created_at, s.title as session_title FROM conversation_messages m JOIN conversation_sessions s ON m.session_id = s.session_id - WHERE m.active = TRUE AND m.content LIKE ? + WHERE m.active = TRUE AND ({or_conds}) """ - params: list[Any] = [f"%{query}%"] + params: list[Any] = [f"%{kw}%" for kw in keywords] if role_filter: sql += " AND m.role = ?" params.append(role_filter) @@ -525,10 +559,11 @@ def mark_compacted(self, session_id: str, message_ids: List[str]) -> None: [session_id, *message_ids], ) - def end_session(self, session_id: str, *, title: str | None = None) -> None: + def end_session(self, session_id: str, *, title: str | None = None, summary: str | None = None) -> None: """Mark a session as inactive (completed/archived). If *title* is provided, the session title is also updated (truncated to 80 chars). + If *summary* is provided, the session summary is persisted (truncated to 500 chars). """ now = time.time() self._execute_write( @@ -540,6 +575,14 @@ def end_session(self, session_id: str, *, title: str | None = None) -> None: "UPDATE conversation_sessions SET title = ?, updated_at = ? WHERE session_id = ?", [title[:80], now, session_id], ) + if summary: + try: + self._execute_write( + "UPDATE conversation_sessions SET summary = ?, updated_at = ? WHERE session_id = ?", + [summary[:500], now, session_id], + ) + except Exception: + logger.debug("conversation_store: summary update failed", exc_info=True) def fork_session( self, @@ -657,13 +700,18 @@ def _row_to_session(self, row: tuple) -> ConversationSession: meta = json.loads(row[11]) if row[11] else {} except (json.JSONDecodeError, IndexError): pass + summary = "" + try: + summary = row[12] or "" if len(row) > 12 else "" + except (IndexError, TypeError): + pass return ConversationSession( session_id=row[0], title=row[1] or "", created_at=row[2] or 0.0, updated_at=row[3] or 0.0, parent_session_id=row[4], model=row[5] or "", source=row[6] or "cli", cwd=row[7] or "", message_count=row[8] or 0, total_tokens=row[9] or 0, is_active=bool(row[10]) if row[10] is not None else True, - metadata=meta, + metadata=meta, summary=summary, ) def _row_to_tool_execution(self, row: tuple) -> "ToolExecutionRecord":