Skip to content

feat: add /feedback command for diagnostic data collection#1078

Draft
RealKai42 wants to merge 2 commits intomainfrom
kaiyi/cli-feedback-feature
Draft

feat: add /feedback command for diagnostic data collection#1078
RealKai42 wants to merge 2 commits intomainfrom
kaiyi/cli-feedback-feature

Conversation

@RealKai42
Copy link
Collaborator

@RealKai42 RealKai42 commented Feb 10, 2026

Summary

Adds a new /feedback command that allows users to submit diagnostic reports directly from the CLI.

Key Changes

  • New feedback module (src/kimi_cli/feedback/):

    • Collects system info, session context, git state, token usage, tool execution metrics, and errors
    • Automatically redacts sensitive data (API keys, credentials, custom headers)
    • Two-phase upload: Phase 1 (basic JSON metadata) → Phase 2 (large attachments: context, logs, source code)
  • UI enhancements:

    • Implements /feedback slash command with interactive prompts
    • Displays feedback summary before submission with optional detail view
    • Shows feedback hints after errors occur
    • Adds feedback shortcut in status bar

User Flow

  1. User types /feedback (optionally with a message)
  2. System collects and displays diagnostic summary (all sensitive data redacted)
  3. User can choose to include source code (git-tracked only)
  4. User confirms submission after reviewing summary
  5. Report uploaded and report ID returned for tracking

Testing

  • Local testing mode via KIMI_FEEDBACK_LOCAL_DIR env var
  • Configurable API endpoint via KIMI_FEEDBACK_API_BASE env var

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked the related issue, if any.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have run make gen-changelog to update the changelog.
  • I have run make gen-docs to update the user documentation.

Open with Devin

Copilot AI review requested due to automatic review settings February 10, 2026 06:57
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 7 additional findings in Devin Review.

Open in Devin Review

# Find ERROR lines
for line in content.split("\n"):
if "| ERROR" in line or "| CRITICAL" in line:
last_error = shorten(line.strip(), width=500)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 last_error from log is not redacted, potentially leaking sensitive data

The last_error field extracted from log lines is not passed through redact_log_content(), unlike recent_exceptions which are redacted at line 180. Error log lines can contain sensitive data such as API keys or Bearer tokens (e.g., in HTTP error response messages logged at ERROR level), which would then be included unredacted in the feedback report sent to the server.

Root Cause

At src/kimi_cli/feedback/collector.py:171, the code assigns:

last_error = shorten(line.strip(), width=500)

But a few lines below at src/kimi_cli/feedback/collector.py:180, traceback text IS redacted:

recent_exceptions.append(redact_log_content(tb_text))

The redact_log_content function (src/kimi_cli/feedback/redact.py:57-62) strips patterns like sk-... API keys, Bearer ... tokens, and api_key=... values. Since last_error skips this redaction, any ERROR/CRITICAL log line containing these patterns would be sent in plaintext to the feedback API.

Impact: Sensitive credentials (API keys, bearer tokens) could be leaked to the feedback collection endpoint if they appear in error log lines.

Suggested change
last_error = shorten(line.strip(), width=500)
last_error = redact_log_content(shorten(line.strip(), width=500))
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new interactive /feedback CLI command to collect and upload diagnostic reports (two-phase upload), and surfaces the feature in the shell UI (welcome text, status bar hint, and error toast hint).

Changes:

  • Implement /feedback slash command flow with interactive prompts and Phase 1/Phase 2 upload.
  • Add kimi_cli.feedback module for diagnostics collection, redaction helpers, display formatting, and uploading (including local debug mode).
  • Add UI hints for /feedback in the bottom toolbar, welcome message, and after certain error conditions.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
src/kimi_cli/ui/shell/slash.py Implements /feedback command (collection, confirmation UI, uploads).
src/kimi_cli/ui/shell/prompt.py Adds /feedback shortcut text to bottom toolbar when space allows.
src/kimi_cli/ui/shell/init.py Shows a toast hinting /feedback after provider/unexpected errors; updates welcome help text.
src/kimi_cli/feedback/uploader.py Implements Phase 1 JSON + Phase 2 multipart upload (and local save mode).
src/kimi_cli/feedback/redact.py Adds config/path/git/log redaction helpers.
src/kimi_cli/feedback/models.py Adds Pydantic models for feedback payloads/responses.
src/kimi_cli/feedback/display.py Renders feedback summary/details in Rich panels/pager.
src/kimi_cli/feedback/collector.py Collects Phase 1 metadata + Phase 2 attachments (context/wire/log/source).
src/kimi_cli/feedback/init.py Exposes feedback public API via re-exports.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +619 to +636
"""Read full context.jsonl, capped at MAX_CONTEXT_SIZE."""
context_path = soul.context.file_backend
if not context_path.exists():
return b""
try:
return await asyncio.to_thread(_read_file_tail_bytes, context_path, MAX_CONTEXT_SIZE)
except Exception:
logger.debug("Failed to read context file")
return b""


async def collect_phase2_wire_tail(soul: KimiSoul) -> bytes:
"""Read last N lines of wire.jsonl."""
wire_path = soul.runtime.session.wire_file.path
if not wire_path.exists():
return b""
try:
return await asyncio.to_thread(_read_file_tail_lines, wire_path, MAX_WIRE_LINES)
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phase 2 collectors return raw context.jsonl and wire.jsonl tails without any redaction/scrubbing, yet these files can contain embedded content (including uploaded file contents) and tool arguments that may include secrets. Add redaction (at least basic secret-pattern scrubbing) and/or filter out high-risk fields before uploading, or gate these attachments behind an explicit opt-in prompt separate from source.zip.

Suggested change
"""Read full context.jsonl, capped at MAX_CONTEXT_SIZE."""
context_path = soul.context.file_backend
if not context_path.exists():
return b""
try:
return await asyncio.to_thread(_read_file_tail_bytes, context_path, MAX_CONTEXT_SIZE)
except Exception:
logger.debug("Failed to read context file")
return b""
async def collect_phase2_wire_tail(soul: KimiSoul) -> bytes:
"""Read last N lines of wire.jsonl."""
wire_path = soul.runtime.session.wire_file.path
if not wire_path.exists():
return b""
try:
return await asyncio.to_thread(_read_file_tail_lines, wire_path, MAX_WIRE_LINES)
"""Read full context.jsonl, capped at MAX_CONTEXT_SIZE, with basic redaction."""
context_path = soul.context.file_backend
if not context_path.exists():
return b""
try:
raw = await asyncio.to_thread(_read_file_tail_bytes, context_path, MAX_CONTEXT_SIZE)
redacted = redact_log_content(raw.decode("utf-8", errors="replace"))
return redacted.encode("utf-8")
except Exception:
logger.debug("Failed to read context file")
return b""
async def collect_phase2_wire_tail(soul: KimiSoul) -> bytes:
"""Read last N lines of wire.jsonl with basic redaction."""
wire_path = soul.runtime.session.wire_file.path
if not wire_path.exists():
return b""
try:
raw = await asyncio.to_thread(_read_file_tail_lines, wire_path, MAX_WIRE_LINES)
redacted = redact_log_content(raw.decode("utf-8", errors="replace"))
return redacted.encode("utf-8")

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +130
console.print(
Panel(
Group(*renderables),
title="Feedback Summary (sensitive data redacted)",
border_style="cyan",
)
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The panel title says "sensitive data redacted", but the summary includes Phase 2 attachment sizes for context/wire/log and the flow can also upload source.zip, some of which are not fully redacted (and source.zip is unredacted by design). Consider tightening the wording to specify exactly what is redacted (e.g., Phase 1 metadata + log scrubbing) to avoid giving users a false sense of safety.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +62
def redact_config(config: Config) -> dict[str, Any]:
"""Serialize config and redact all sensitive fields."""
data = config.model_dump(mode="json", exclude_none=True)

# Redact providers
for provider_data in data.get("providers", {}).values():
if "api_key" in provider_data:
provider_data["api_key"] = REDACTED
if "oauth" in provider_data and provider_data["oauth"]:
provider_data["oauth"]["key"] = REDACTED
if "custom_headers" in provider_data:
provider_data["custom_headers"] = REDACTED
if "env" in provider_data:
provider_data["env"] = REDACTED

# Redact services
services = data.get("services", {})
if services:
for svc_name in ("moonshot_search", "moonshot_fetch"):
svc = services.get(svc_name)
if not svc:
continue
if "api_key" in svc:
svc["api_key"] = REDACTED
if "custom_headers" in svc:
svc["custom_headers"] = REDACTED
if "oauth" in svc and svc["oauth"]:
svc["oauth"]["key"] = REDACTED

return data


def anonymize_path(path: str) -> str:
"""Replace home directory with ~."""
home = str(Path.home())
if path.startswith(home):
return "~" + path[len(home) :]
return path


def redact_git_url(url: str) -> str:
"""Remove auth tokens from git URLs."""
return re.sub(r"(https?://)([^@]+)@", r"\1***@", url)


def redact_log_content(content: str) -> str:
"""Remove secret patterns from log content."""
content = re.sub(r"(sk-)[a-zA-Z0-9]{10,}", r"\1***", content)
content = re.sub(r"(Bearer\s+)[a-zA-Z0-9._-]+", r"\1***", content)
content = re.sub(r"(api_key[\"\s=:]+)[\"']?[a-zA-Z0-9._-]+", r"\1***", content)
return content
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New redaction helpers (redact_config/redact_git_url/redact_log_content/anonymize_path) are security-sensitive, but there are no unit tests validating that common secret patterns and config fields are reliably redacted. Add focused tests covering provider api_key, oauth.key, custom_headers, and log/token patterns to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +403 to +408
async for record in soul.wire_file.iter_records():
msg = record.to_wire_message()
if first_ts is None:
first_ts = record.timestamp
last_ts = record.timestamp

Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_collect_wire_metrics() iterates the entire wire.jsonl via soul.wire_file.iter_records() with no size/record cap. For long sessions this can make /feedback noticeably slow (replay_recent_history explicitly skips wire files >20MB); consider adding a similar size guard and/or scanning only the tail needed for summary metrics.

Suggested change
async for record in soul.wire_file.iter_records():
msg = record.to_wire_message()
if first_ts is None:
first_ts = record.timestamp
last_ts = record.timestamp
# Avoid scanning excessively large wire files, which can make /feedback slow.
# Mirror the behavior of replay_recent_history, which skips wire files >20MB.
max_wire_bytes = 20 * 1024 * 1024
wire_path = getattr(soul.wire_file, "path", None)
wire_size: int | None = None
if wire_path is not None:
try:
wire_size = os.path.getsize(wire_path)
except OSError:
wire_size = None
if wire_size is not None and wire_size > max_wire_bytes:
logger.info(
"Skipping wire scan for feedback metrics: wire file too large (%s bytes)",
wire_size,
)
else:
async for record in soul.wire_file.iter_records():
msg = record.to_wire_message()
if first_ts is None:
first_ts = record.timestamp
last_ts = record.timestamp

Copilot uses AI. Check for mistakes.
Comment on lines 191 to 224
def _collect_chat_summary(soul: KimiSoul) -> ChatSummary:
"""Get representative messages: first 3 + last 7 (deduped), each truncated."""
history = soul.context.history
total_count = len(history)
messages: list[ChatMessage] = []

# Select representative messages: first 3 + last 7 (dedup when overlapping)
selected = list(history) if total_count <= 10 else list(history[:3]) + list(history[-7:])

for msg in selected:
# Extract text content
content_text = ""
try:
content_text = msg.extract_text(" ")
except Exception:
for part in msg.content:
if hasattr(part, "text"):
content_text += getattr(part, "text", "")
content_text = shorten(content_text, width=500, placeholder="...(truncated)")

# Extract tool call names
tool_call_names: list[str] | None = None
if msg.tool_calls:
tool_call_names = [tc.function.name for tc in msg.tool_calls]

messages.append(
ChatMessage(
role=msg.role,
content=content_text,
tool_calls=tool_call_names,
)
)

return ChatSummary(total_count=total_count, messages=messages)
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chat message excerpts collected in _collect_chat_summary() are uploaded in Phase 1 but are not run through any redaction/scrubbing. Since user/assistant messages can easily contain API keys, tokens, or credentials, this is a high-risk data leak; apply secret-pattern redaction (similar to redact_log_content) to content_text (and potentially tool_call_names/arguments if later added) before including it in Phase1Request.

Copilot uses AI. Check for mistakes.
stdout, _ = await proc.communicate()
if proc.returncode == 0:
info.commit = stdout.decode().strip()
except Exception:
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
)
await proc.communicate()
info.dirty = proc.returncode != 0
except Exception:
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
stdout, _ = await proc.communicate()
if proc.returncode == 0:
info.remote_url = redact_git_url(stdout.decode().strip())
except Exception:
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
Comment on lines 521 to 522
except Exception:
pass
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
except Exception:
pass
except Exception as exc:
# Git information is optional; failures here should not break feedback collection.
logger.debug("Failed to count git tracked files for %s: %s", work_dir, exc)

Copilot uses AI. Check for mistakes.
Comment on lines 347 to 348
except Exception:
pass
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
except Exception:
pass
except Exception as e:
# Phase 2 data is optional; log and continue with empty values.
console.print(f"[yellow]Warning:[/yellow] Failed to collect optional diagnostic data: {e}")

Copilot uses AI. Check for mistakes.
@RealKai42 RealKai42 marked this pull request as draft February 10, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments