Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dashscope/acli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import annotations

__version__ = "0.6.0"
__version__ = "0.6.2"

# Expose the lightweight programmatic SDK at the package root.
try:
Expand Down
82 changes: 76 additions & 6 deletions dashscope/acli/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,32 @@ def reset(self):
self.messages = []

def load_session(self) -> int:
"""Restore self.messages from session_path. Returns the number of
messages loaded (0 if no file, file empty, or parse failed)."""
if not self.session_path or not self.session_path.exists():
"""Restore self.messages. Returns the number of messages loaded
(0 if nothing could be restored).

The per-topic event stream is the source of truth: the latest
``messages/snapshot`` wins when present (crash recovery via
append-only replay; torn trailing lines are skipped). The
``history.json`` file remains as the compat fallback for
sessions written before snapshots existed.
"""
if not self.session_path:
return 0
try:
from dashscope.acli.session_events import (
EVENTS_FILENAME,
SessionEventLog,
latest_snapshot_messages,
)

log = SessionEventLog(self.session_path.parent / EVENTS_FILENAME)
resumed = latest_snapshot_messages(log.read_raw())
if resumed:
self.messages = resumed
return len(resumed)
except Exception:
pass
if not self.session_path.exists():
return 0
try:
data = json.loads(self.session_path.read_text())
Expand Down Expand Up @@ -326,11 +349,25 @@ def _system_prompt_for_turn(self, user_input_text: str) -> str:
experience_tracker=self.experience_tracker,
disabled_caps_provider=self.disabled_caps_provider,
directives_provider=self.directives_provider,
scene_provider=self._scene_section,
current_turn_tools=self._current_turn_tools,
connected_mcp_services=self._connected_mcp_services,
)
return self._prompt_pipeline.render(ctx)

def _scene_section(self) -> str:
"""Scene memory of the current session topic (best-effort).

Subagents and SDK callers may run without a session manager;
any failure simply yields no scene section.
"""
try:
from dashscope.acli.session import get_session_manager

return get_session_manager().get_scene()
except Exception:
return ""

def _reflection_section(self) -> str:
"""Inject reflection hints when repeated failures detected."""
tracker = self.memory_manager.session.reflection
Expand Down Expand Up @@ -524,9 +561,9 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]:
async for chunk in self.provider.chat_stream(
normalize_for_model(messages_with_system, self.model_name),
tools_schema,
response_format={"type": "json_object"}
if self.json_mode
else None,
response_format=(
{"type": "json_object"} if self.json_mode else None
),
):
if chunk.delta_content:
full_content += chunk.delta_content
Expand Down Expand Up @@ -765,6 +802,39 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]:
),
)

# Record the completed turn as events (session-as-event-log
# direction) BEFORE persisting history.json: the event stream is
# the source of truth, so it must never be older than the
# fallback store if we crash between the two writes. Best-effort:
# subagents/SDK callers may run without a session manager, and
# event recording must never break the loop.
try:
from dashscope.acli.session import get_session_manager

turn_topic = (
self.session_path.parent.name if self.session_path else None
)
get_session_manager().record_turn_event(
user_text=text_of(user_input),
assistant_text=last_content,
tools_used=self._current_turn_tools,
outcome=(
_classify_outcome(
self._turn_tool_successes,
self._turn_tool_failures,
)
if self._current_turn_tools
else ""
),
topic=turn_topic,
)
get_session_manager().record_messages_snapshot(
self.messages,
topic=turn_topic,
)
except Exception:
pass

# Persist session before memory write so a memory exception can't
# cost us the conversation.
self.save_session()
Expand Down
8 changes: 2 additions & 6 deletions dashscope/acli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,15 @@
_handle_slash_command,
dispatch_async_command,
)
from dashscope.acli.cli.examples import ( # noqa: E402
_handle_example_command,
)
from dashscope.acli.cli.examples import _handle_example_command # noqa: E402
from dashscope.acli.cli.handlers_capability import ( # noqa: F401,E402
_cap_enabled,
sync_extensions_into_catalog,
)
from dashscope.acli.cli.handlers_misc import ( # noqa: F401,E402
_handle_report_command,
)
from dashscope.acli.cli.handlers_setup import ( # noqa: F401,E402
_handle_setup,
)
from dashscope.acli.cli.handlers_setup import _handle_setup # noqa: F401,E402

# Import MCP management from submodule
from dashscope.acli.cli.mcp import ( # noqa: F401,E402
Expand Down
3 changes: 2 additions & 1 deletion dashscope/acli/cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
"/report",
"/feedback",
"/history",
"/undo",
"/json",
"/save",
"/privacy",
Expand Down Expand Up @@ -228,7 +229,7 @@
"/mcp": ["list", "add", "remove"],
"/cron": ["add", "list", "remove", "pause", "resume"],
"/feedback": ["good", "bad"],
"/history": ["stats", "list", "export", "clear"],
"/history": ["stats", "list", "search", "export", "clear"],
"/json": ["on", "off"],
"/privacy": ["on", "off", "status"],
"/audit": ["recent", "query", "clear"],
Expand Down
5 changes: 5 additions & 0 deletions dashscope/acli/cli/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,11 @@ def _handle_slash_command(
elif cmd.startswith("/history"):
_handle_history_command(cmd)
return True
elif cmd == "/undo":
from dashscope.acli.tools.checkpoint import handle_undo_command

handle_undo_command()
return True
elif cmd.startswith("/privacy"):
_handle_privacy_command(cmd, config)
return True
Expand Down
124 changes: 123 additions & 1 deletion dashscope/acli/cli/handlers_misc.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# -*- coding: utf-8 -*-
"""Miscellaneous command handlers (trust, history, report)."""
# pylint: disable=protected-access,too-many-branches,too-many-statements
# pylint: disable=too-many-return-statements

from __future__ import annotations

from typing import Any

from rich.console import Console
from rich.markup import escape

from dashscope.acli.agent import Agent

Expand Down Expand Up @@ -77,6 +81,86 @@ def _handle_trust_command(cmd: str, agent: Agent) -> None:
)


def _message_text(msg: dict[str, Any]) -> str:
"""Flatten a chat message's content into one-line plain text."""
content = msg.get("content", "")
if isinstance(content, list):
content = " ".join(
part.get("text", "") for part in content if isinstance(part, dict)
)
if not isinstance(content, str):
return ""
return " ".join(content.split())


def _search_snippet(text: str, idx: int, kw_len: int) -> str:
"""Build a short one-line snippet centered on a match position."""
start = max(0, idx - 20)
end = min(len(text), idx + kw_len + 40)
prefix = "..." if start > 0 else ""
suffix = "..." if end < len(text) else ""
return prefix + text[start:end] + suffix


def _highlight_keyword(text: str, keyword: str) -> str:
"""Wrap keyword occurrences in rich markup (case-insensitive)."""
lower_kw = keyword.lower()
if not lower_kw:
return escape(text)
lower_text = text.lower()
out: list[str] = []
pos = 0
while True:
idx = lower_text.find(lower_kw, pos)
if idx < 0:
out.append(escape(text[pos:]))
break
out.append(escape(text[pos:idx]))
out.append("[bold yellow]")
out.append(escape(text[idx : idx + len(keyword)]))
out.append("[/bold yellow]")
pos = idx + len(keyword)
return "".join(out)


def _history_search_matches(
keyword: str,
limit: int = 20,
) -> list[dict[str, str]]:
"""Case-insensitive substring search across all session history.

Scans every stored session's messages and returns up to ``limit``
matches, each carrying the session topic, a timestamp, the message
role, and a one-line snippet with match context.
"""
from dashscope.acli.session import get_session_manager

needle = keyword.lower()
if not needle or limit <= 0:
return []
mgr = get_session_manager()
matches: list[dict[str, str]] = []
for meta in mgr.list_topics():
if len(matches) >= limit:
break
for msg in mgr.load_messages(meta.topic):
text = _message_text(msg)
idx = text.lower().find(needle) if text else -1
if idx < 0:
continue
matches.append(
{
"session": meta.topic,
"timestamp": meta.last_accessed or "",
"role": str(msg.get("role", "?")),
"snippet": _search_snippet(text, idx, len(needle)),
},
)
if len(matches) >= limit:
break
return matches


def _handle_history_command(cmd: str) -> None:
"""Manage conversation history."""
from dashscope.acli.platforms.local.history import (
Expand Down Expand Up @@ -107,6 +191,8 @@ def _handle_history_command(cmd: str) -> None:
"\n[dim]Usage:\n"
" /history stats — show stats\n"
" /history list [n] — list recent n\n"
" /history search <keyword> [limit] — full-text "
"search\n"
" /history export <file> [--format json|md|html] — export\n"
" /history clear — clear "
"history[/dim]",
Expand Down Expand Up @@ -147,6 +233,41 @@ def _handle_history_command(cmd: str) -> None:
)
return

if sub == "search":
if len(parts) < 3:
console.print(
"[dim]Usage: /history search <keyword> [limit][/dim]",
)
return
keyword = parts[2]
limit = 20
if len(parts) >= 4:
try:
limit = int(parts[3])
except ValueError:
limit = 0
if limit <= 0:
console.print(
"[red]limit must be a positive integer[/red]",
)
return
matches = _history_search_matches(keyword, limit=limit)
if not matches:
console.print(f"[dim]No matches for '{keyword}'[/dim]")
return
header = f"[bold]{len(matches)} match(es) for '{keyword}':[/bold]"
console.print(header)
for i, m in enumerate(matches, 1):
ts = m["timestamp"][:16]
head = (
f" {i}. [cyan]{m['session']}[/cyan] "
f"[dim]{ts}[/dim] [bold]{m['role']}[/bold]"
)
console.print(head)
snippet = _highlight_keyword(m["snippet"], keyword)
console.print(f" {snippet}")
return

if sub == "export" and len(parts) >= 3:
output_path = parts[2]
fmt = "html"
Expand Down Expand Up @@ -174,7 +295,8 @@ def _handle_history_command(cmd: str) -> None:
console.print(f"[green]✓ Cleared {count} history records[/green]")
return

console.print("[dim]Usage: /history [stats|list|export|clear][/dim]")
usage = "[dim]Usage: /history [stats|list|search|export|clear][/dim]"
console.print(usage)


def _handle_report_command(agent: Agent) -> None:
Expand Down
Loading
Loading