diff --git a/.planning/user-input-history-mcp/findings.md b/.planning/user-input-history-mcp/findings.md new file mode 100644 index 0000000..3db4498 --- /dev/null +++ b/.planning/user-input-history-mcp/findings.md @@ -0,0 +1,79 @@ +# Findings & Decisions: 用户输入历史 MCP + +## Requirements + +- 新增一个专用 ccweb MCP,只读返回用户输入内容列表。 +- 可指定目标对话;未指定时默认当前来源对话。 +- 默认返回最近 15 次用户输入。 +- 所有返回输入正文合计最多 1000 个 Unicode 字符,并明确标记截断或仍有更早内容。 +- 引导表单提交后插入为普通用户消息的内容必须包含在内。 +- 结果供 LLM 回看用户要求、作为实现验收参考。 + +## Research Findings + +- 当前暴露的 `ccweb_list_conversations` 只返回会话元数据,明确不返回对话正文。 +- 当前 ccweb MCP 工具清单没有读取会话用户输入历史的工具。 +- 仓库已有其他活跃计划,当前任务使用 `.planning/user-input-history-mcp/` 隔离,避免覆盖根目录或其他任务计划。 +- Trellis 开发者身份已初始化为 `shiyue`;共享 `.trellis/.current-task` 指向其他任务,因此本任务不覆盖该共享指针。 +- codebase-memory 项目 `home-cc-web` 索引状态为 `ready`(6308 nodes / 14349 edges),可直接用于函数级定位。 +- 第二次独立计划审查已通过,契约足以进入代码研究与实施。 +- MCP 公共工具定义集中在 `lib/ccweb-mcp-server.js` 的 `TOOLS`;现有 `ccweb_list_conversations`、`ccweb_prompt_user` 均在此声明 schema。 +- 内部 MCP 分派入口是 `server.js` 的 `callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount)`,现有列表工具转到 `listConversationSummaries`。 +- `listConversationSummaries` 已提供来源会话清洗、limit clamp、会话文件枚举和运行状态等可复用模式;正文读取应另建只读函数,避免扩大摘要接口职责。 +- 引导表单链路关键函数为 `createCcwebPromptUser`、`handleCcwebPromptUserResponse`、`buildCcwebPromptUserResponseText`;需读取精确源码确认提交内容最终是否由普通 `handleMessage` 持久化为 `role=user`。 +- 已确认引导表单提交链路:`handleCcwebPromptUserResponse` 用 `buildCcwebPromptUserResponseText` 生成问题/选择/答案正文,再调用普通 `handleMessage(..., { emitUserMessage: true })`;因此提交结果会成为普通 `role=user` 消息,新工具只按角色筛选即可自然纳入。 +- 待填写的引导卡本身是 `role=assistant` 且携带 `ccwebPrompt`,必须排除;只有用户提交后的普通消息才应返回。 +- `loadSession(id)` 已是统一、安全的会话 JSON 读取入口,包含 ID 清洗、大小限制、会话规范化和运行时线程索引更新;新查询不应自行读文件。 +- 公共 MCP `tools/list` 读取 `lib/ccweb-mcp-server.js` 的 `TOOLS`,共享 HTTP/JSON-RPC 调用经 `handleMcpJsonRpcMessage` 与 `isCallableToolName` 进入 `callInternalMcpTool`。 +- `isCallableToolName` 会自动接受所有 `TOOLS` 中的公开工具;新增工具无需维护额外 allowlist。`CODEX_APP_COMMUNICATION_TOOL_NAMES` 只控制旧 dynamic-tool 兼容集,是否纳入需结合兼容路径调用点判断。 +- 现有回归函数 `assertCcwebListConversationsScopeContract` 同时断言正式 MCP schema、兼容工具定义和内部分派源码;新工具应补同等级 schema/route 契约,并通过内部 MCP HTTP 做数据行为验证。 +- `server.js` 的 composer 候选和共享 MCP `tools/list` 都直接展开 `CCWEB_MCP_TOOLS`,因此加入 `TOOLS` 后会自动出现在 `/` MCP mention 与正式 MCP 列表中。 +- 旧 Codex App dynamic-tool 兼容路径由 `CODEX_APP_COMMUNICATION_TOOL_NAMES` 精确筛选,并最终仍调用同一个 `callInternalMcpTool`。新工具与 `ccweb_list_conversations` 同属只读会话通信能力,应纳入该兼容集合;正式 MCP 仍是主路径。 +- 测试编写阶段检查工作区时,仅本任务计划/Trellis 文件与测试代理临时清单为新增文件,尚无生产代码改动或与用户既有改动冲突。 +- 新增聚焦测试 `scripts/ccweb-list-user-inputs-unit.js`,覆盖正式 schema、JSON-RPC/旧内部路由、默认/指定会话、15 条、角色筛选、引导提交、Unicode 总预算、错误码与只读性。 +- 首次执行按预期失败:公开 `TOOLS` 尚无新工具,调用返回 `unknown_tool`;只读 fixture hash/mtime 断言已独立通过,说明红灯指向缺失实现而非测试环境破坏。 +- 主审阅发现初版测试有两处需修正:禁止覆盖 `HOME`;提交表单正文会合法包含表单标题,排除未提交卡应按角色/消息 ID 断言。 +- codebase-memory 一次搜索误报项目未索引,但随后的 `list_projects`/`index_status` 显示 `home-cc-web` 仍为 ready(6387 nodes / 14484 edges),属于瞬时查询异常,无需重建索引。 +- codebase-memory 收敛重试又返回 `Transport closed`,已按降级规则停用本轮该查询并改用本地 `rg/sed`;定位范围仅限 `sanitizeId`、`truncateTextValue`、`normalizeSession`。 +- 测试代理已移除 HOME 覆盖、改用消息 ID 排除未提交引导卡,并增加临时目录清理;复跑仍只因 `unknown_tool`/缺失 schema 失败。 +- 生产实现新增 `listUserInputHistory`,使用 `loadSession` 只读筛选 `role=user`,从最新向前分配 Unicode code point 总预算,再按旧到新返回。 +- 正式 `TOOLS`、Codex App 旧兼容集合和 `callInternalMcpTool` 已统一注册 `ccweb_list_user_inputs`,没有第二套实现。 +- 主线程与实现代理在交接边界发生一次 apply_patch 上下文冲突;补丁工具安全拒绝覆盖,最终 diff 仅包含代理的目标改动,无丢失或重复代码。 +- 语法检查和聚焦测试全部通过,包含 fixture 文件 hash/mtime 不变断言。 +- 既有 `ccweb-message-reply-unit` 通过,说明新增 fallback 工具未破坏统一发送/旧别名契约。 +- 完整 `npm run regression` 在约 37 秒内通过;`git diff --check` 无空白或补丁格式问题。 +- 独立 Trellis 质量审查已通过,无阻断问题;补强了空会话、预算恰好用满(有/无更早输入)和运行时 clamp 测试,复跑通过。 +- 运行态检查发现除当前对话外,`63b18af6-1b9a-4ad0-8627-f2b5ba22fc39` 仍为 running;按仓库运维规则不得重启 cc-web,因此当前进程尚未加载新 MCP,需待其他对话空闲后重启。 + +## Finalized Query Semantics + +- `conversationId`:显式传入则读取该会话;缺省才回退到来源会话。显式无效 ID 不得静默回退。 +- `limit`:默认 15,允许 1–50;先从全部非空 `role=user` 文本中取最近 N 条。 +- `maxChars`:默认 1000,允许 1–1000;以 JavaScript Unicode code point(`Array.from`)计数,约束所有返回 `content` 合计。 +- 预算分配:从最新候选向更早候选分配;遇到剩余预算不足时保留该消息开头并标记 `truncated=true`,更早候选不再返回;最后按旧到新排序。 +- `hasMore=true`:存在 limit 之外的更早用户输入,或字数预算截断/省略了候选。 +- 空白/空正文 user 消息不作为“内容”返回;引导表单提交正文是非空普通 user 消息,因此自然包含。 +- 缺少来源会话返回 `missing_source_conversation`;显式无效 ID 返回 `invalid_conversation_id`;会话不存在返回 `conversation_not_found`。 +- 返回项包含 `messageId|null`、`createdAt|null`、`content`、`contentChars`、`originalChars`、`truncated`;顶层包含会话 ID、limit/maxChars、总字符数、返回数与 `hasMore`。 +- Trellis backend 规范目前大多是占位内容;本改动不涉及“部分 JSON 预览”专项规则。实现应沿用现有 `loadSession`、`mcpToolError` 和 `scripts/regression.js` 的实际项目惯例。 +- 已创建隔离 Trellis 任务 `.trellis/tasks/08-17-user-input-history-mcp/` 并写入完整 PRD;因共享 current task 属于其他工作,本任务不改写 `.trellis/.current-task`。 + +## Technical Decisions + +| Decision | Rationale | +|----------|-----------| +| 工具采用只读查询 | 不应改变会话、消息或运行状态 | +| 当前会话默认值由 MCP 服务的来源会话上下文解析 | 与已有跨会话工具的线程级来源语义保持一致 | +| 返回最近 N 条后再按旧到新输出 | 查询高效,同时方便 LLM 顺序理解需求演进 | +| 1000 字作为整体硬预算,从最新输入向前填充 | 限制最坏上下文体积并优先保留最新验收要求 | + +## Issues Encountered + +| Issue | Resolution | +|-------|------------| +| “最多 1000 字”存在逐条或整体预算歧义 | 独立计划审查后选择更保守的整体硬预算,并增加截断/更多标记 | + +## Resources + +- `.trellis/workflow.md` +- `AGENTS.md` 中 ccweb MCP 与线程级来源上下文约定 diff --git a/.planning/user-input-history-mcp/progress.md b/.planning/user-input-history-mcp/progress.md new file mode 100644 index 0000000..3f07385 --- /dev/null +++ b/.planning/user-input-history-mcp/progress.md @@ -0,0 +1,84 @@ +# Progress Log: 用户输入历史 MCP + +## Session: 2026-08-17 + +### Phase 1: 代码发现与契约确认 + +- **Status:** complete +- **Started:** 2026-08-17 +- Actions taken: + - 核对当前暴露的 ccweb MCP 工具清单,确认缺少用户输入历史读取能力。 + - 读取项目工作流以及计划跟踪技能要求。 + - 建立隔离的持久计划与已知需求记录。 + - 完成第一次独立计划审查;根据审查意见补齐整体字数预算、MCP 契约和关键验收用例。 + - 第二次独立计划审查通过。 + - 确认 codebase-memory 索引可用;避免覆盖共享 Trellis 当前任务指针。 + - 定位 MCP schema、内部路由、会话列表和引导表单响应的函数级入口。 + - 定稿查询参数、Unicode 计数、预算顺序、错误码、返回字段和 `hasMore` 语义。 + - 读取 Trellis backend 规范;确认无额外目录或错误处理约束,继续遵循现有项目模式。 + - 创建 Trellis 任务并写入 PRD、implement/check 上下文,未覆盖其他任务的共享 current 指针。 + - 确认正式 MCP、composer 候选和旧 Codex App 兼容工具的定义复用关系。 + - 派出 Trellis 测试实现代理;其当前只做代码定位,生产文件保持未修改。 + - 测试代理新增聚焦失败测试并确认红灯;未修改任何生产文件,已清理其临时 TODO CSV。 + - 主线程复跑失败测试:1.02 秒退出,7 项因缺少工具/schema 路由失败,只读性断言通过。 +- Files created/modified: + - `.planning/user-input-history-mcp/task_plan.md` + - `.planning/user-input-history-mcp/findings.md` + - `.planning/user-input-history-mcp/progress.md` + +### Phase 2: 测试与实现 + +- **Status:** complete +- Actions taken: + - 完成失败测试并进入最小生产实现。 + - 实现 `ccweb_list_user_inputs` 查询、正式 schema、兼容集合和内部统一路由。 + - 运行两个语法检查与聚焦测试,全部通过。 + - 运行既有通信单测、完整 regression 与 diff 检查,全部通过。 +- Files created/modified: + - `scripts/ccweb-list-user-inputs-unit.js`(新增测试) + - `server.js`(查询逻辑与路由) + - `lib/ccweb-mcp-server.js`(正式 schema 与兼容工具集合) + +### Phase 3: 验证与交付 + +- **Status:** complete +- Actions taken: + - 完整 regression、独立质量审查与补强边界测试全部通过。 + - 检查运行会话;因存在另一个 running 对话,按项目规则暂缓服务重启并记录交付限制。 +- Files created/modified: + - `.planning/user-input-history-mcp/*`(任务审计记录) + - `.trellis/tasks/08-17-user-input-history-mcp/*`(PRD 与上下文) + +## Test Results + +| Test | Input | Expected | Actual | Status | +|------|-------|----------|--------|--------| +| 聚焦测试红灯 | `node scripts/ccweb-list-user-inputs-unit.js` | 因工具未实现而失败 | 7 项 `unknown_tool`/缺失 schema 失败,只读断言通过,1.02s | ✓ | +| 生产文件语法 | `node --check server.js`;`node --check lib/ccweb-mcp-server.js` | 通过 | 通过 | ✓ | +| 聚焦测试绿灯 | `node scripts/ccweb-list-user-inputs-unit.js` | 8 类断言全部通过 | 全部通过,1.16s | ✓ | +| 既有通信单测 | `node scripts/ccweb-message-reply-unit.js` | 通过 | 通过 | ✓ | +| 完整回归 | `npm run regression` | 60 秒内通过 | 约 37 秒通过 | ✓ | +| Diff 检查 | `git diff --check` | 无错误 | 无错误 | ✓ | +| 补强边界测试 | 聚焦脚本:空会话、预算恰好用满、运行时 clamp | 全部通过 | 全部通过,0.82s | ✓ | +| 独立质量审查 | Trellis checker 只读审查 | 无阻断问题 | 已通过 | ✓ | +| 运行态重启门禁 | `ccweb_list_conversations(status=running)` | 仅当前对话时才重启 | 发现另一个 running 对话,按规则暂缓重启 | ✓ | + +## Error Log + +| Timestamp | Error | Attempt | Resolution | +|-----------|-------|---------|------------| +| 2026-08-17 | 计划对 1000 字限制的解释可能导致最多约 15000 字输出 | 1 | 改为所有返回正文合计最多 1000 字,并明确预算算法 | +| 2026-08-17 | 初版失败测试覆盖 HOME 且误排除提交表单标题 | 1 | 退回测试代理按环境变量规则和消息 ID 修正 | +| 2026-08-17 | codebase-memory 搜索瞬时返回 project not indexed | 1 | 用 list_projects/index_status 复核为 ready,避免无谓重建 | +| 2026-08-17 | codebase-memory 收敛查询返回 Transport closed | 2 | 停止重复调用,降级为 rg/sed 精确读取目标函数 | +| 2026-08-17 | 主线程 apply_patch 与代理最后写入并发导致上下文不匹配 | 1 | 补丁未应用;审阅现有 diff 后确认目标代码完整,再运行验证 | + +## 5-Question Reboot Check + +| Question | Answer | +|----------|--------| +| Where am I? | Phase 3:验证与交付已完成 | +| Where am I going? | 待其他对话空闲后由运维重启加载新 MCP | +| What's the goal? | 新增可读取指定/当前对话用户输入历史的只读 MCP | +| What have I learned? | 引导表单提交落为普通 user 消息;正式/兼容 MCP 复用同一路由 | +| What have I done? | 已实现、测试和审查新 MCP;因运行态门禁暂缓重启 | diff --git a/.planning/user-input-history-mcp/task_plan.md b/.planning/user-input-history-mcp/task_plan.md new file mode 100644 index 0000000..a11c3f4 --- /dev/null +++ b/.planning/user-input-history-mcp/task_plan.md @@ -0,0 +1,79 @@ +# Task Plan: 新增用户输入历史 MCP + +## Goal + +为 cc-web 增加一个只读 MCP,可读取指定对话(默认当前来源对话)最近的用户输入,纳入引导表单提交内容,并把所有返回正文控制在合计 1000 个 Unicode 字符以内,供 LLM 作为验收参考。 + +## Current Phase + +Phase 3 + +## Phases + +### Phase 1: 代码发现与契约确认 + +- [x] 梳理会话消息存储与 ccweb MCP 注册链路 +- [x] 明确用户输入筛选、默认会话与截断规则 + - 必须确认普通用户输入与引导表单提交的持久化形态。 + - 必须确认来源会话缺失、指定会话不存在时的错误语义。 +- **Status:** complete + +### Phase 2: 测试与实现 + +- [x] 编写新 MCP 的失败回归测试 + - 覆盖默认当前对话、显式指定对话、默认最近 15 条、仅返回 user 内容。 + - 覆盖引导表单提交消息、合计 1000 字预算、截断标记及缺失来源会话。 +- [x] 实现指定/当前会话的用户输入读取与限制逻辑 +- [x] 注册 MCP schema、描述与运行时上下文 +- **Status:** complete + +### Phase 3: 验证与交付 + +- [x] 运行针对性测试并修复发现的问题 +- [x] 执行相关回归、核验接口并整理交付 +- **Status:** complete + +## Key Questions + +1. 普通用户输入和引导表单提交在持久层中如何标记、是否都使用 `role=user`?(已确认:提交后经普通 `handleMessage` 持久化为 `role=user`;待提交卡是 `role=assistant`) +2. 当前来源对话 ID 从哪个线程级运行时上下文解析?(已确认:JSON-RPC/内部 HTTP 上下文把 `sourceSessionId` 传给 `callInternalMcpTool`) +3. MCP 工具注册、参数校验和权限边界由哪些模块负责?(已确认:`lib/ccweb-mcp-server.js` 的 `TOOLS`/`isCallableToolName` 与 `server.js` 的内部 MCP 分派) + +## Decisions Made + +| Decision | Rationale | +|----------|-----------| +| 默认返回最近 15 条,最终按时间从旧到新排列 | 兼顾最近性与作为验收标准时的可读性 | +| 所有返回正文合计默认且硬性最多 1000 个 Unicode 字符 | 避免 15 条输入最坏膨胀到约 15000 字,保证验收上下文可控 | +| 从最新输入向前消耗字数预算,最终按旧到新输出 | 优先保留最新验收要求,同时维持自然阅读顺序 | +| 预算导致内容或更早消息未完整返回时给出截断/更多标记 | 避免 LLM 将不完整历史误认为完整要求 | +| 仅返回用户输入,不混入 assistant/tool/system 内容 | 保持用户原始验收意图纯净 | + +## MCP Contract + +- 工具名为 `ccweb_list_user_inputs`。 +- 入参: + - `conversationId?: string`:缺省时使用 MCP 来源会话 ID。 + - `limit?: integer`:默认 15,允许 1–50。 + - `maxChars?: integer`:默认 1000,允许 1–1000;约束所有 `items[].content` 的字符总和。 +- 返回: + - `conversationId`、`requestedLimit`、`maxChars`、`totalChars`、`returnedCount`、`hasMore`。 + - `items[]` 至少含 `content`、`createdAt`、`truncated`;若持久层有稳定消息 ID,则同时返回 `messageId`。 +- 排序与预算:先选最近 `limit` 条用户输入,再从最新向前填充 `maxChars` 预算,最终将已选内容按旧到新返回。 +- 默认当前会话但运行时没有来源会话 ID 时返回明确参数错误;显式指定不存在的会话时返回 not-found 错误。 + +## Errors Encountered + +| Error | Attempt | Resolution | +|-------|---------|------------| +| 计划审查指出 1000 字是逐条还是合计存在歧义 | 1 | 收紧为所有返回正文合计硬上限 1000 字,并固化预算顺序 | +| codebase-memory 单次查询误报项目未索引 | 1 | 立即用 list_projects/index_status 复核,项目实际为 ready;后续收敛查询重试,不触发无谓重建 | +| codebase-memory 重试返回 Transport closed | 2 | 停止重复 MCP 调用,降级到本地 rg/sed 精确核验小函数 | +| 初版失败测试覆盖 HOME 且误排除提交表单标题 | 1 | 已退回测试代理修正环境隔离与角色/ID 断言后再验红灯 | +| 主线程生产补丁因代理并发落盘而校验失败 | 1 | apply_patch 安全拒绝覆盖;检查 git diff 后确认代理已完成目标改动,直接进入验证 | + +## Notes + +- 计划与根目录 CSV、`update_plan` 保持一一同步。 +- 研究发现写入 `findings.md`,测试结果写入 `progress.md`。 +- 当前运行进程尚未重载:发现另一个非当前对话仍为 running,按项目约定暂缓 `pm2 restart ccweb --update-env`。 diff --git a/.trellis/tasks/08-17-user-input-history-mcp/check.jsonl b/.trellis/tasks/08-17-user-input-history-mcp/check.jsonl new file mode 100644 index 0000000..b0307ff --- /dev/null +++ b/.trellis/tasks/08-17-user-input-history-mcp/check.jsonl @@ -0,0 +1,2 @@ +{"file":".trellis/spec/backend/error-handling.md","reason":"核验错误码、失败结果和默认会话行为与项目模式一致。"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"核验只读行为、边界条件与回归覆盖。"} diff --git a/.trellis/tasks/08-17-user-input-history-mcp/implement.jsonl b/.trellis/tasks/08-17-user-input-history-mcp/implement.jsonl new file mode 100644 index 0000000..bc4e357 --- /dev/null +++ b/.trellis/tasks/08-17-user-input-history-mcp/implement.jsonl @@ -0,0 +1,2 @@ +{"file":".trellis/spec/backend/error-handling.md","reason":"沿用项目现有 MCP 错误返回模式,避免引入不一致的异常协议。"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"遵循后端读取与回归质量约束。"} diff --git a/.trellis/tasks/08-17-user-input-history-mcp/prd.md b/.trellis/tasks/08-17-user-input-history-mcp/prd.md new file mode 100644 index 0000000..0e23065 --- /dev/null +++ b/.trellis/tasks/08-17-user-input-history-mcp/prd.md @@ -0,0 +1,88 @@ +# PRD:用户输入历史 MCP + +## 目标 + +新增公开只读 MCP `ccweb_list_user_inputs`,让 LLM 在实现或验收前读取指定 cc-web 对话(默认当前来源对话)最近的用户输入,降低实现偏离用户原始要求的概率。 + +## 范围 + +- 在正式 ccweb MCP `tools/list` 中暴露工具。 +- 在内部 MCP 分派中实现只读查询。 +- 从持久会话的 `messages` 中只筛选非空 `role=user` 文本。 +- 引导表单提交后形成的普通 user 消息必须自然包含;未提交的 assistant 引导卡必须排除。 +- 补 schema 契约测试、查询行为测试和相关回归。 + +不包含 UI、消息修改、全文搜索、assistant/tool/system 内容、附件正文解析或跨会话权限模型改造。 + +## MCP 契约 + +### 入参 + +- `conversationId?: string`:指定目标会话;缺省时使用 MCP 来源会话 ID。 +- `limit?: integer`:默认 15,最小 1,最大 50。 +- `maxChars?: integer`:默认 1000,最小 1,最大 1000;限制所有 `items[].content` 的 Unicode code point 总数。 +- `additionalProperties: false`。 + +### 选择与截断 + +1. 只选择正文非空的 `role=user` 消息。 +2. 先取最近 `limit` 条候选。 +3. 从最新候选向更早候选分配 `maxChars` 预算。 +4. 若剩余预算不足,保留当前消息开头,设置 `truncated=true`,停止加入更早消息。 +5. 最终按旧到新排列,便于 LLM 理解要求演进。 +6. Unicode 字符使用 code point 计数,不能按 UTF-16 code unit 把 emoji 拆成两字。 + +### 返回 + +成功结果: + +```json +{ + "ok": true, + "conversationId": "...", + "requestedLimit": 15, + "maxChars": 1000, + "totalChars": 123, + "returnedCount": 2, + "hasMore": false, + "items": [ + { + "messageId": "...或 null", + "createdAt": "...或 null", + "content": "用户输入", + "contentChars": 4, + "originalChars": 4, + "truncated": false + } + ] +} +``` + +`hasMore=true` 表示存在 limit 之外的更早用户输入,或字数预算截断/省略了候选。 + +### 错误 + +- 默认模式缺少来源会话:`missing_source_conversation`。 +- 显式 `conversationId` 清洗后无效:`invalid_conversation_id`,不得回退来源会话。 +- 目标会话不存在:`conversation_not_found`。 + +## 验收标准 + +- 工具 schema 与描述明确说明默认当前会话、最近 15 条、合计最多 1000 字和验收用途。 +- 默认当前来源会话与显式指定会话均可查询。 +- 默认只返回最近 15 条用户输入,并按旧到新排列。 +- assistant/tool/system、空白 user 消息和未提交引导卡不进入结果。 +- 引导表单提交产生的普通 user 消息进入结果。 +- `limit` 与 `maxChars` 被正确默认和限制;所有正文总字符数不超过 `maxChars`。 +- emoji 等非 BMP 字符按单个 code point 计数。 +- 截断项、`hasMore`、计数字段和错误码可由回归测试稳定断言。 +- 查询不修改会话文件、更新时间、未读状态或运行状态。 +- 针对性测试与项目相关 regression 均通过。 + +## 实施顺序 + +1. 先写失败测试固定 schema、路由与行为。 +2. 实现最小查询和 Unicode 预算逻辑。 +3. 注册正式 MCP schema;仅在现有兼容策略确有需要时纳入旧 dynamic-tool 兼容集。 +4. 跑针对性测试,再跑相关完整回归。 + diff --git a/.trellis/tasks/08-17-user-input-history-mcp/task.json b/.trellis/tasks/08-17-user-input-history-mcp/task.json new file mode 100644 index 0000000..37c3cde --- /dev/null +++ b/.trellis/tasks/08-17-user-input-history-mcp/task.json @@ -0,0 +1,26 @@ +{ + "id": "user-input-history-mcp", + "name": "user-input-history-mcp", + "title": "新增用户输入历史 MCP", + "description": "新增 ccweb_list_user_inputs,只读返回指定或当前对话最近用户输入,供 LLM 验收参考。", + "status": "completed", + "dev_type": "backend", + "scope": "lib/ccweb-mcp-server.js, server.js, scripts/regression.js", + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-17", + "completedAt": "2026-08-17", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 57a4521..de77473 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index 114aea8..e6bffbd 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -18,6 +18,7 @@ const CCWEB_REPLY_MODES = Object.freeze({ const CCWEB_SEND_MESSAGE_DESCRIPTION = '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。必须填写 replyMode:仅当来源不需要目标结果时使用 one_way;涉及分析、实现、测试、验收、完成后汇报或来源后续依赖目标结果时,必须使用 return_and_continue。工具调用会立即返回,不阻塞也不等待目标对话完成。'; const CODEX_APP_COMMUNICATION_TOOL_NAMES = new Set([ 'ccweb_list_conversations', + 'ccweb_list_user_inputs', 'ccweb_set_title', 'ccweb_create_conversation', 'ccweb_send_message', @@ -75,6 +76,32 @@ const TOOLS = [ additionalProperties: false, }, }, + { + name: 'ccweb_list_user_inputs', + description: '该工具返回用户输入的近 N 次对话列表。', + inputSchema: { + type: 'object', + properties: { + conversationId: { + type: 'string', + description: '可选。目标对话 ID;缺省时使用当前 MCP 来源对话。', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 50, + description: '可选。最多返回最近多少条用户输入,默认 15。', + }, + maxChars: { + type: 'integer', + minimum: 1, + maximum: 1000, + description: '可选。所有返回正文合计最多多少个 Unicode 字符,默认 1000。', + }, + }, + additionalProperties: false, + }, + }, { name: 'ccweb_set_title', description: '设置当前 ccweb 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。', diff --git a/scripts/ccweb-list-user-inputs-unit.js b/scripts/ccweb-list-user-inputs-unit.js new file mode 100644 index 0000000..75ee990 --- /dev/null +++ b/scripts/ccweb-list-user-inputs-unit.js @@ -0,0 +1,472 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert/strict'); +const crypto = require('crypto'); +const fs = require('fs'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const REPO_DIR = path.resolve(__dirname, '..'); +const SERVER_PATH = path.join(REPO_DIR, 'server.js'); +const { TOOLS } = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server')); + +const INTERNAL_MCP_TOKEN = 'ListUserInputsUnitMcp!234'; + +function mkdirp(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = address && typeof address === 'object' ? address.port : null; + server.close(() => resolve(port)); + }); + }); +} + +async function waitForPort(port, timeoutMs = 10000) { + const started = Date.now(); + let lastError = null; + while (Date.now() - started < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(); + }); + socket.once('error', reject); + socket.setTimeout(500, () => { + socket.destroy(); + reject(new Error('timeout')); + }); + }); + return; + } catch (err) { + lastError = err; + await sleep(50); + } + } + throw new Error(`Timed out waiting for port ${port}: ${lastError?.message || 'unknown'}`); +} + +async function withServer(env, fn) { + const child = spawn(process.execPath, [SERVER_PATH], { + cwd: REPO_DIR, + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + + try { + await waitForPort(env.PORT, 10000); + await fn({ stdout: () => stdout, stderr: () => stderr }); + } finally { + if (child.exitCode === null && !child.signalCode) { + child.kill('SIGTERM'); + await sleep(300); + } + if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL'); + } +} + +async function postJson(port, pathname, body) { + const response = await fetch(`http://127.0.0.1:${port}${pathname}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CC-Web-MCP-Token': INTERNAL_MCP_TOKEN, + }, + body: JSON.stringify(body), + }); + let payload = null; + try { + payload = await response.json(); + } catch {} + return { status: response.status, payload }; +} + +async function mcpJsonRpc(port, sourceSessionId, message) { + const query = sourceSessionId ? `?sourceSessionId=${encodeURIComponent(sourceSessionId)}` : ''; + return postJson(port, `/api/internal/mcp/stream${query}`, message); +} + +async function callSharedTool(port, sourceSessionId, args = {}) { + const response = await mcpJsonRpc(port, sourceSessionId, { + jsonrpc: '2.0', + id: crypto.randomUUID(), + method: 'tools/call', + params: { + name: 'ccweb_list_user_inputs', + arguments: args, + }, + }); + assert.equal(response.status, 200, `tools/call should return HTTP 200, got ${response.status}`); + assert.ok(response.payload?.result, 'tools/call should return a JSON-RPC result'); + return response.payload.result.structuredContent; +} + +async function directInternalCall(port, sourceSessionId, args = {}) { + const response = await postJson(port, '/api/internal/mcp', { + tool: 'ccweb_list_user_inputs', + args, + sourceSessionId, + }); + return response; +} + +function writeSession(sessionsDir, session) { + const filePath = path.join(sessionsDir, `${session.id}.json`); + fs.writeFileSync(filePath, JSON.stringify(session, null, 2)); + return filePath; +} + +function sha256(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function userMessage(id, content) { + return { + id, + role: 'user', + content, + timestamp: `2026-08-17T00:${String(Number(id.replace(/\D/g, '')) || 0).padStart(2, '0')}:00.000Z`, + }; +} + +function createFixtures(sessionsDir) { + const sourceMessages = []; + for (let index = 1; index <= 18; index += 1) { + sourceMessages.push(userMessage(`u${String(index).padStart(2, '0')}`, `用户输入 ${String(index).padStart(2, '0')}`)); + } + sourceMessages.splice(4, 0, + { id: 'assistant-guidance', role: 'assistant', content: 'assistant 说明不应进入结果', timestamp: '2026-08-17T00:04:10.000Z' }, + { id: 'tool-result', role: 'tool', content: '工具输出不应进入结果', timestamp: '2026-08-17T00:04:20.000Z' }, + { id: 'blank-user', role: 'user', content: ' \n\t', timestamp: '2026-08-17T00:04:30.000Z' }, + { + id: 'assistant-prompt-card', + role: 'assistant', + content: '', + timestamp: '2026-08-17T00:04:40.000Z', + ccwebPrompt: { + id: 'prompt-1', + status: 'pending', + title: '需要确认', + questions: [{ id: 'q1', title: '方向', question: '选择方向' }], + }, + }, + ); + sourceMessages.push(userMessage('submitted-form', '表单答案:\n\n表单:需要确认\n\n1. 方向\n答案:继续实现最小方案')); + + const sourcePath = writeSession(sessionsDir, { + id: 'source-session', + title: '来源会话', + agent: 'codexapp', + created: '2026-08-17T00:00:00.000Z', + updated: '2026-08-17T00:30:00.000Z', + hasUnread: false, + isRunning: false, + messages: sourceMessages, + }); + + const explicitPath = writeSession(sessionsDir, { + id: 'explicit-session', + title: '显式会话', + agent: 'codexapp', + created: '2026-08-17T01:00:00.000Z', + updated: '2026-08-17T01:10:00.000Z', + hasUnread: true, + isRunning: false, + messages: [ + userMessage('explicit-old', '显式旧输入'), + { id: 'explicit-assistant', role: 'assistant', content: '显式 assistant' }, + userMessage('explicit-new', '显式新输入'), + ], + }); + + const unicodePath = writeSession(sessionsDir, { + id: 'unicode-session', + title: 'Unicode 会话', + agent: 'codexapp', + created: '2026-08-17T02:00:00.000Z', + updated: '2026-08-17T02:10:00.000Z', + messages: [ + userMessage('unicode-old', 'abcdefghij'), + userMessage('unicode-mid', '一二三四'), + userMessage('unicode-latest', '🙂🙂🙂🙂'), + ], + }); + + const exactSinglePath = writeSession(sessionsDir, { + id: 'exact-single-session', + title: '恰好预算单消息', + agent: 'codexapp', + messages: [userMessage('exact-single', '🙂甲')], + }); + + const exactMorePath = writeSession(sessionsDir, { + id: 'exact-more-session', + title: '恰好预算仍有更早消息', + agent: 'codexapp', + messages: [ + userMessage('exact-old', '旧'), + userMessage('exact-latest', '🙂甲'), + ], + }); + + const emptyPath = writeSession(sessionsDir, { + id: 'empty-session', + title: '无有效用户正文', + agent: 'codexapp', + messages: [ + { id: 'empty-assistant', role: 'assistant', content: '仅 assistant' }, + { id: 'empty-user', role: 'user', content: ' \n\t ' }, + ], + }); + + return { + sourcePath, + explicitPath, + unicodePath, + exactSinglePath, + exactMorePath, + emptyPath, + }; +} + +function assertToolSchema() { + const tool = TOOLS.find((item) => item.name === 'ccweb_list_user_inputs'); + assert.ok(tool, '正式 ccweb MCP tools/list 应暴露 ccweb_list_user_inputs'); + assert.equal(tool.description, '该工具返回用户输入的近 N 次对话列表。'); + + const schema = tool.inputSchema; + assert.equal(schema?.type, 'object'); + assert.equal(schema.additionalProperties, false, 'schema additionalProperties 必须为 false'); + assert.ok(!Array.isArray(schema.required) || !schema.required.includes('conversationId'), 'conversationId 必须可选'); + assert.deepEqual(schema.properties?.conversationId?.type, 'string'); + assert.equal(schema.properties?.limit?.type, 'integer'); + assert.equal(schema.properties?.limit?.minimum, 1); + assert.equal(schema.properties?.limit?.maximum, 50); + assert.match(schema.properties?.limit?.description || '', /默认\s*15|15/); + assert.equal(schema.properties?.maxChars?.type, 'integer'); + assert.equal(schema.properties?.maxChars?.minimum, 1); + assert.equal(schema.properties?.maxChars?.maximum, 1000); + assert.match(schema.properties?.maxChars?.description || '', /默认\s*1000|1000/); +} + +async function assertServerToolList(port) { + const response = await mcpJsonRpc(port, 'source-session', { + jsonrpc: '2.0', + id: 'tools-list', + method: 'tools/list', + params: {}, + }); + assert.equal(response.status, 200); + const toolNames = response.payload?.result?.tools?.map((tool) => tool.name) || []; + assert.ok(toolNames.includes('ccweb_list_user_inputs'), '内部 JSON-RPC tools/list 应包含 ccweb_list_user_inputs'); +} + +async function assertDefaultsAndFiltering(port) { + const payload = await callSharedTool(port, 'source-session', {}); + assert.equal(payload.ok, true); + assert.equal(payload.conversationId, 'source-session'); + assert.equal(payload.requestedLimit, 15); + assert.equal(payload.maxChars, 1000); + assert.equal(payload.returnedCount, 15); + assert.equal(payload.items.length, 15); + assert.equal(payload.hasMore, true, '超过默认 limit 的更早用户输入应设置 hasMore=true'); + assert.deepEqual(payload.items.map((item) => item.messageId), [ + 'u05', 'u06', 'u07', 'u08', 'u09', + 'u10', 'u11', 'u12', 'u13', 'u14', + 'u15', 'u16', 'u17', 'u18', 'submitted-form', + ]); + const allContent = payload.items.map((item) => item.content).join('\n'); + assert.match(allContent, /表单答案/, '提交后的普通 user 消息应进入结果'); + assert.doesNotMatch(allContent, /assistant 说明|工具输出/, 'assistant/tool 独有内容不应进入结果'); + assert.ok( + !payload.items.some((item) => item.messageId === 'assistant-prompt-card'), + '未提交的 assistant 引导卡不应以 messageId 进入结果' + ); + assert.ok(payload.totalChars <= 1000); + for (const item of payload.items) { + assert.equal(item.contentChars, Array.from(item.content).length); + assert.equal(item.truncated, false); + } +} + +async function assertExplicitConversation(port) { + const payload = await callSharedTool(port, 'source-session', { conversationId: 'explicit-session', limit: 10 }); + assert.equal(payload.ok, true); + assert.equal(payload.conversationId, 'explicit-session'); + assert.deepEqual(payload.items.map((item) => item.content), ['显式旧输入', '显式新输入']); +} + +async function assertUnicodeBudgetAndTruncation(port) { + const payload = await callSharedTool(port, 'source-session', { + conversationId: 'unicode-session', + limit: 3, + maxChars: 6, + }); + assert.equal(payload.ok, true); + assert.equal(payload.totalChars, 6); + assert.equal(payload.returnedCount, 2); + assert.equal(payload.hasMore, true); + assert.deepEqual(payload.items.map((item) => item.messageId), ['unicode-mid', 'unicode-latest']); + assert.equal(payload.items[0].content, '一二'); + assert.equal(payload.items[0].contentChars, 2); + assert.equal(payload.items[0].originalChars, 4); + assert.equal(payload.items[0].truncated, true); + assert.equal(payload.items[1].content, '🙂🙂🙂🙂'); + assert.equal(payload.items[1].contentChars, 4, 'emoji 应按 Unicode code point 计数,而不是 UTF-16 code unit'); + assert.equal(payload.items[1].originalChars, 4); + assert.equal(payload.items[1].truncated, false); + assert.ok(payload.items.reduce((sum, item) => sum + item.contentChars, 0) <= 6); +} + +async function assertExactBudgetEmptyAndClamps(port) { + const exactSingle = await callSharedTool(port, 'source-session', { + conversationId: 'exact-single-session', + maxChars: 2, + }); + assert.equal(exactSingle.totalChars, 2); + assert.equal(exactSingle.hasMore, false, '恰好用满预算且无更早输入时不应误报 hasMore'); + assert.equal(exactSingle.items[0].truncated, false); + + const exactMore = await callSharedTool(port, 'source-session', { + conversationId: 'exact-more-session', + maxChars: 2, + }); + assert.equal(exactMore.totalChars, 2); + assert.equal(exactMore.returnedCount, 1); + assert.equal(exactMore.items[0].messageId, 'exact-latest'); + assert.equal(exactMore.items[0].truncated, false); + assert.equal(exactMore.hasMore, true, '恰好用满预算但仍有更早输入时应标记 hasMore'); + + const empty = await callSharedTool(port, 'source-session', { conversationId: 'empty-session' }); + assert.equal(empty.returnedCount, 0); + assert.equal(empty.totalChars, 0); + assert.equal(empty.hasMore, false); + assert.deepEqual(empty.items, []); + + const clamped = await callSharedTool(port, 'source-session', { + conversationId: 'explicit-session', + limit: 999, + maxChars: 9999, + }); + assert.equal(clamped.requestedLimit, 50); + assert.equal(clamped.maxChars, 1000); +} + +async function assertErrors(port) { + const missing = await callSharedTool(port, '', {}); + assert.equal(missing.ok, false); + assert.equal(missing.code, 'missing_source_conversation'); + + const invalid = await callSharedTool(port, 'source-session', { conversationId: '!!!', limit: 5 }); + assert.equal(invalid.ok, false); + assert.equal(invalid.code, 'invalid_conversation_id'); + + const notFound = await callSharedTool(port, 'source-session', { conversationId: 'missing-session', limit: 5 }); + assert.equal(notFound.ok, false); + assert.equal(notFound.code, 'conversation_not_found'); +} + +async function assertDirectInternalRoute(port) { + const response = await directInternalCall(port, 'source-session', { limit: 1 }); + assert.equal(response.status, 200); + assert.equal(response.payload?.ok, true); + assert.equal(response.payload?.conversationId, 'source-session'); + assert.equal(response.payload?.returnedCount, 1); +} + +async function runTest(name, fn, failures) { + try { + await fn(); + console.log(`ok - ${name}`); + } catch (err) { + failures.push({ name, err }); + console.error(`not ok - ${name}`); + console.error(` ${err.stack || err.message}`); + } +} + +async function main() { + const failures = []; + await runTest('正式 MCP schema', assertToolSchema, failures); + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-list-user-inputs-')); + try { + const sessionsDir = path.join(tempRoot, 'sessions'); + const configDir = path.join(tempRoot, 'config'); + const logsDir = path.join(tempRoot, 'logs'); + mkdirp(sessionsDir); + mkdirp(configDir); + mkdirp(logsDir); + const fixturePaths = createFixtures(sessionsDir); + const before = Object.fromEntries(Object.entries(fixturePaths).map(([key, filePath]) => { + const stat = fs.statSync(filePath); + return [key, { filePath, hash: sha256(filePath), mtimeMs: stat.mtimeMs }]; + })); + + const port = await getFreePort(); + await withServer({ + PORT: String(port), + CC_WEB_PASSWORD: 'ListUserInputsUnit!234', + CC_WEB_INTERNAL_MCP_TOKEN: INTERNAL_MCP_TOKEN, + CC_WEB_CONFIG_DIR: configDir, + CC_WEB_SESSIONS_DIR: sessionsDir, + CC_WEB_LOGS_DIR: logsDir, + CC_WEB_USAGE_STATISTICS: '0', + }, async () => { + await runTest('内部 JSON-RPC tools/list 暴露工具', () => assertServerToolList(port), failures); + await runTest('默认来源会话、最近 15 条、过滤和旧到新排序', () => assertDefaultsAndFiltering(port), failures); + await runTest('显式指定 conversationId 查询目标会话', () => assertExplicitConversation(port), failures); + await runTest('maxChars Unicode code point 预算和截断字段', () => assertUnicodeBudgetAndTruncation(port), failures); + await runTest('恰好预算、空会话和运行时 clamp 边界', () => assertExactBudgetEmptyAndClamps(port), failures); + await runTest('错误码 missing_source_conversation / invalid_conversation_id / conversation_not_found', () => assertErrors(port), failures); + await runTest('旧版内部 /api/internal/mcp 路由分派', () => assertDirectInternalRoute(port), failures); + }); + + await runTest('查询不修改 fixture 会话文件', () => { + for (const entry of Object.values(before)) { + const stat = fs.statSync(entry.filePath); + assert.equal(sha256(entry.filePath), entry.hash, `${path.basename(entry.filePath)} 内容不应变化`); + assert.equal(stat.mtimeMs, entry.mtimeMs, `${path.basename(entry.filePath)} mtime 不应变化`); + } + }, failures); + } finally { + try { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } catch (err) { + failures.push({ name: '清理临时目录', err }); + console.error('not ok - 清理临时目录'); + console.error(` ${err.stack || err.message}`); + } + } + + if (failures.length > 0) { + console.error(`\n${failures.length} test(s) failed.`); + process.exitCode = 1; + return; + } + console.log('\nall ccweb_list_user_inputs focused tests passed'); +} + +main().catch((err) => { + console.error(err.stack || err.message); + process.exit(1); +}); diff --git a/server.js b/server.js index 45d9b08..c04302d 100644 --- a/server.js +++ b/server.js @@ -5475,6 +5475,18 @@ function clampMcpLimit(value) { return Math.max(1, Math.min(100, parsed)); } +function clampUserInputsLimit(value) { + const parsed = Number.parseInt(String(value || ''), 10); + if (!Number.isFinite(parsed)) return 15; + return Math.max(1, Math.min(50, parsed)); +} + +function clampUserInputsMaxChars(value) { + const parsed = Number.parseInt(String(value || ''), 10); + if (!Number.isFinite(parsed)) return 1000; + return Math.max(1, Math.min(1000, parsed)); +} + function listConversationSummaries(args = {}, sourceSessionId = '') { reconcilePendingCrossConversationReplies(); const scope = args.scope === 'children' ? 'children' : 'all'; @@ -5532,6 +5544,90 @@ function listConversationSummaries(args = {}, sourceSessionId = '') { }; } +function listUserInputHistory(args = {}, sourceSessionId = '') { + const hasExplicitConversationId = Object.prototype.hasOwnProperty.call(args, 'conversationId'); + let conversationId; + if (hasExplicitConversationId) { + const rawConversationId = String(args.conversationId || '').trim(); + const sanitizedConversationId = sanitizeId(rawConversationId); + if (!sanitizedConversationId || sanitizedConversationId !== rawConversationId) { + return mcpToolError('invalid_conversation_id', 'conversationId 无效。', { + conversationId: rawConversationId || null, + }); + } + conversationId = sanitizedConversationId; + } else { + conversationId = sanitizeId(sourceSessionId || ''); + if (!conversationId) { + return mcpToolError('missing_source_conversation', '缺少来源对话 ID。'); + } + } + + const session = loadSession(conversationId); + if (!session) { + return mcpToolError('conversation_not_found', '目标对话不存在。', { + conversationId, + }); + } + + const requestedLimit = clampUserInputsLimit(args.limit); + const maxChars = clampUserInputsMaxChars(args.maxChars); + const userMessages = (Array.isArray(session.messages) ? session.messages : []) + .filter((message) => message?.role === 'user' && typeof message.content === 'string' && message.content.trim()) + .map((message) => ({ + messageId: message.id || message.messageId || null, + createdAt: message.createdAt || message.timestamp || null, + content: message.content, + })); + const candidates = userMessages.slice(-requestedLimit); + const items = []; + let remainingChars = maxChars; + let totalChars = 0; + let budgetLimited = false; + + for (let index = candidates.length - 1; index >= 0; index -= 1) { + if (remainingChars <= 0) { + budgetLimited = true; + break; + } + + const candidate = candidates[index]; + const codePoints = Array.from(candidate.content); + const originalChars = codePoints.length; + const truncated = originalChars > remainingChars; + const content = truncated ? codePoints.slice(0, remainingChars).join('') : candidate.content; + const contentChars = truncated ? remainingChars : originalChars; + + items.push({ + messageId: candidate.messageId, + createdAt: candidate.createdAt, + content, + contentChars, + originalChars, + truncated, + }); + totalChars += contentChars; + remainingChars -= contentChars; + + if (truncated) { + budgetLimited = true; + break; + } + } + + items.reverse(); + return { + ok: true, + conversationId, + requestedLimit, + maxChars, + totalChars, + returnedCount: items.length, + hasMore: userMessages.length > candidates.length || budgetLimited || items.length < candidates.length, + items, + }; +} + function normalizeMcpPromptText(value, maxChars) { if (value === null || value === undefined) return ''; return truncateTextValue(String(value).trim(), maxChars, '...'); @@ -6296,6 +6392,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) { return createCcwebDisplayImage(args, sourceSessionId); case 'ccweb_list_conversations': return listConversationSummaries(args, sanitizeId(sourceSessionId || '')); + case 'ccweb_list_user_inputs': + return listUserInputHistory(args, sourceSessionId); case 'ccweb_set_title': return setCurrentConversationTitle(args, sourceSessionId); case 'ccweb_create_conversation':