diff --git a/.github/release-notes/v1.1.3.md b/.github/release-notes/v1.1.3.md index 909a204e3..2a01c4795 100644 --- a/.github/release-notes/v1.1.3.md +++ b/.github/release-notes/v1.1.3.md @@ -14,3 +14,18 @@ - Memmy Desktop and Agent are version `1.1.3`. The bundled Memory service, viewer, and `memmy-memory` CLI use the independent version `2.1.1`. - Memory continues running after Desktop exits unless **Stop Memory when quitting** is enabled. Standalone Memory CLI installations retain their operating-system service management. + +## 修复 + +- 修复 Windows 和 macOS 安装包中的 Memory 启动、重启问题。桌面端直接管理随包提供的 Memory,避免 Windows 计划任务权限错误和 macOS 系统服务重启冲突。 +- 修复退出桌面端后,后台 Memory 无法从查看器重启的问题。桌面端连接断开后,Memory 仍可重新加载配置并重启服务,支持连续重启。 +- 桌面端会在旧 Memory 完成迁移、释放数据库锁后,将自己管理的运行时升级到随包版本;保留兼容的独立安装和较新版本。 +- 修复 Linux CLI 发布时,版本分支与标签同名导致构建取到旧分支的问题。 +- 自定义 BYOK 模型未被内置能力目录收录时,也可接收图片输入;明确标记为仅支持文本的模型仍沿用原有回退方式。 +- 账号模式下的 Memory 摘要改用专用摘要模型,并兼容旧配置迁移。 +- Windows Memory 后台服务改用隐藏启动器并将日志写入文件,避免登录和重启服务时留下命令窗口。任务定义兼容中文路径,结束计划任务后会在短暂清理窗口后停止其 Memory 子进程;安装、启动及复用已有兼容运行时时会更新旧启动器。旧计划任务因权限不足无法更新时,不再阻断桌面端使用或启动 Memory。 + +## 升级说明 + +- 桌面端和 Agent 版本为 `1.1.3`;随包提供的 Memory、查看器和 `memmy-memory` CLI 独立升级为 `2.1.1`。 +- 未开启“退出时停止 Memory”时,退出桌面端后 Memory 继续在后台运行。单独安装的 Memory CLI 保留系统服务管理方式。 diff --git a/.github/release-notes/v1.1.4.md b/.github/release-notes/v1.1.4.md deleted file mode 100644 index 1f0d05200..000000000 --- a/.github/release-notes/v1.1.4.md +++ /dev/null @@ -1,27 +0,0 @@ -# Memmy v1.1.4 - -## Fixes - -- Incremental Agent-history scans retain their saved progress and avoid importing a long conversation's earlier turns again when new messages arrive. -- Each Agent conversation turn now produces one memory, avoiding many small fragments from tool-heavy exchanges. Oversized content is truncated with a visible marker. -- Upgrades preserve existing Agent-source scan checkpoints and deduplication records across account changes. -- Account mode applies its dedicated Memory models without retaining stale BYOK connections, while preserving the selected local Embedding option. -- Fixed native SQLite asset loading and cache reuse when Memory runs as a `pkg` executable. -- Fixed local AgentSourceCore dependency resolution when building the macOS Memory runtime. - -## Upgrade notes - -- Memmy Desktop and Agent are version `1.1.4`. The bundled Memory service, viewer, and CLI use the independent version `2.1.2`. - -## 修复 - -- 增量扫描保留 Agent 历史记录的扫描进度,长对话新增消息后不再重复导入之前的轮次。 -- 一轮 Agent 对话生成一条记忆,避免包含大量工具调用的对话被拆成许多零碎记忆;超长内容会截断并标明。 -- 升级时保留已有扫描检查点和去重记录,避免切换账号后重复扫描。 -- 账号模式正确应用专用 Memory 模型,清理残留的 BYOK 连接,同时保留用户选择的本地 Embedding。 -- 修复 Memory 通过 `pkg` 可执行程序运行时,SQLite 原生组件的加载与缓存复用问题。 -- 修复 macOS Memory 运行时打包时,本地 AgentSourceCore 依赖的解析问题。 - -## 升级说明 - -- 桌面端和 Agent 版本为 `1.1.4`;随包提供的 Memory、查看器和 CLI 独立升级为 `2.1.2`。 diff --git a/.github/release-notes/v1.1.5.md b/.github/release-notes/v1.1.5.md index a3957ad7a..49ecec6bf 100644 --- a/.github/release-notes/v1.1.5.md +++ b/.github/release-notes/v1.1.5.md @@ -2,19 +2,31 @@ ## Highlights -- Duplicate attachment drops are ignored without hiding real validation errors. -- WebUI and API uploads accept more than four attachments and files larger than the former per-file limits. -- The request body limit for attachment uploads is 256 MB. -- Includes the documentation, screenshot, and GitHub/Yunxiao synchronization updates already merged into main since v1.1.4. +- Agent-source capture now keeps complete Codex turns, pairs tool calls with their surrounding turn, and filters the imported L1 history more accurately. New native scan and source-turn storage paths preserve the turn boundary and support large histories safely. +- Memory can be packaged and run as a standalone executable. Runtime installation now handles packaged assets, native dependencies, version/help reporting, and transformer loading consistently. +- Added StepFun and Xiaomi MiMo text providers, with catalog validation derived from the provider contract. +- Expanded attachments so document content is represented by a manifest, image/document buffering is more reliable, and duplicate attachment drops are ignored safely. +- Added the PDF, DOCX, PPTX, and XLSX document skills and their validation tooling, together with the required notices and platform payload manifests. +- Improved world-model detail rendering, work-memory processing, model filtering, and desktop update/toolbar behavior. +- Synced the release branch with the latest `main` changes, including the current Yunxiao GitHub synchronization workflow and documentation cleanup. ## Upgrade notes - Memmy Desktop and Agent are version `1.1.5`. -- The bundled Memory service, viewer, and CLI remain on independent version `2.1.2`. +- The bundled Memory service, viewer, and CLI use the independent version `2.1.3`. - Memory protocol version remains `1`. ## 修复与更新 -- 重复添加的附件会静默忽略,真正的格式或读取错误仍会提示。 -- WebUI 和 API 上传不再限制附件数量及单文件大小,请求体上限调整为 256 MB。 -- 包含 v1.1.4 之后已合入 main 的文档、截图及 GitHub/云效同步更新。 +- Agent 来源采集现在按完整 Codex 对话轮次写入记忆,正确配对工具调用并改进 L1 历史过滤;新增原生扫描和 source-turn 存储路径,可更稳定地处理大规模历史记录。 +- Memory 支持打包为独立可执行程序,统一处理打包资源、原生依赖、版本/帮助信息和 transformers 加载。 +- 新增 StepFun 和 Xiaomi MiMo 文本模型提供商,模型目录校验改为从提供商契约派生。 +- 附件处理改为使用文档清单,改进图片/文档缓冲,并安全忽略重复的附件拖放。 +- 新增 PDF、DOCX、PPTX、XLSX 文档技能及其校验工具、许可说明和平台资源清单。 +- 改进世界模型详情渲染、工作记忆处理、模型过滤以及桌面端更新和工具栏布局。 +- 发布分支同步最新 `main`,包含当前云效 GitHub 同步流程及文档清理。 + +## 升级说明 + +- 桌面端和 Agent 版本为 `1.1.5`;随包提供的 Memory、查看器和 CLI 独立升级为 `2.1.3`。 +- Memory 协议版本保持为 `1`。 diff --git a/.github/workflows/computer-use-native.yml b/.github/workflows/computer-use-native.yml new file mode 100644 index 000000000..c1e223c59 --- /dev/null +++ b/.github/workflows/computer-use-native.yml @@ -0,0 +1,82 @@ +name: Computer Use native runtime + +on: + pull_request: + paths: + - ".github/workflows/computer-use-native.yml" + - "App/memmy-agent/package*.json" + - "App/memmy-agent/src/tools/computer-use/**" + - "App/shell/desktop/electron-builder*.yml" + - "scripts/internal/shared/check-open-computer-use.mjs" + - "scripts/internal/linux/**" + - "scripts/internal/mac/**" + - "tests/ocu-dev-install.test.mjs" + - "scripts/internal/win/build-nsis.sh" + workflow_dispatch: + +permissions: + contents: read + +jobs: + native: + strategy: + fail-fast: false + matrix: + runner: [ubuntu-24.04, ubuntu-24.04-arm, macos-15, windows-2025] + runs-on: ${{ matrix.runner }} + timeout-minutes: 10 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install the pinned native package into an isolated directory + run: | + version="$(node -p "require('./App/memmy-agent/package.json').dependencies['open-computer-use']")" + npm install --prefix "$RUNNER_TEMP/ocu-native" --ignore-scripts --no-audit --no-fund "open-computer-use@$version" + node --input-type=module <<'NODE' + import fs from 'node:fs'; + import path from 'node:path'; + const root = path.join(process.env.RUNNER_TEMP, 'ocu-native/node_modules/open-computer-use'); + const arch = process.arch === 'x64' ? 'amd64' : process.arch; + const relative = process.platform === 'darwin' + ? 'dist/Open Computer Use.app/Contents/MacOS/OpenComputerUse' + : `dist/${process.platform === 'win32' ? 'windows' : 'linux'}/${arch}/open-computer-use${process.platform === 'win32' ? '.exe' : ''}`; + fs.appendFileSync(process.env.GITHUB_ENV, `OCU_BINARY=${path.join(root, relative)}\n`); + NODE + - name: Prepare an isolated Linux desktop session + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y dbus-x11 xvfb + bash scripts/internal/linux/install-computer-use-deps.sh + bash scripts/internal/linux/install-computer-use-deps.sh + - name: Check Linux MCP and desktop backend + if: runner.os == 'Linux' + run: dbus-run-session -- xvfb-run -a node scripts/internal/shared/check-open-computer-use.mjs "$OCU_BINARY" --list-apps | tee ocu-native-result.json + - name: Check Windows MCP and desktop backend + if: runner.os == 'Windows' + run: node scripts/internal/shared/check-open-computer-use.mjs "$OCU_BINARY" --list-apps | tee ocu-native-result.json + - name: Validate the published macOS bundle and installer + if: runner.os == 'macOS' + run: | + codesign --verify --deep --strict "$RUNNER_TEMP/ocu-native/node_modules/open-computer-use/dist/Open Computer Use.app" + lipo "$OCU_BINARY" -verify_arch arm64 x86_64 + node --test tests/ocu-dev-install.test.mjs + - name: Check macOS MCP discovery + if: runner.os == 'macOS' + run: node scripts/internal/shared/check-open-computer-use.mjs "$OCU_BINARY" | tee ocu-native-result.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: ocu-native-${{ matrix.runner }} + path: ocu-native-result.json + if-no-files-found: warn + - name: Record validation boundary + if: always() + run: echo 'Native checks validate MCP discovery and, on Linux/Windows, desktop enumeration. Signed installers, permission prompts, screenshots and input still require desktop acceptance.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index fd5658ae0..badaa064e 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -174,24 +174,22 @@ jobs: test "$(git rev-parse HEAD)" = "$TARGET_SHA" - name: Preflight Doc Agent draft endpoint - id: doc_agent env: DOC_AGENT_RELEASE_NOTES_DRAFT_URL: ${{ vars.DOC_AGENT_RELEASE_NOTES_DRAFT_URL || secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_URL }} DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} run: | set -euo pipefail - echo "available=false" >> "$GITHUB_OUTPUT" if [[ -z "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" ]]; then - echo "::warning title=Doc Agent draft URL is missing::The full release run will create a safe needs-review Draft instead of stopping. Configure DOC_AGENT_RELEASE_NOTES_DRAFT_URL to restore generated copy." >&2 - exit 0 + echo "::error title=Doc Agent draft URL is missing::Configure DOC_AGENT_RELEASE_NOTES_DRAFT_URL before merging release branches. Manual recovery: add the Actions variable pointing to the 106 /internal/memmy-release-notes/draft endpoint, then re-run smoke." >&2 + exit 1 fi if [[ -z "$DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" ]]; then - echo "::warning title=Doc Agent draft token is missing::The full release run will create a safe needs-review Draft instead of stopping. Configure DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN to restore generated copy." >&2 - exit 0 + echo "::error title=Doc Agent draft token is missing::Configure DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN before merging release branches. Manual recovery: add the Actions secret matching the 106 RELEASE_NOTES_DRAFT_TOKEN, then re-run smoke." >&2 + exit 1 fi if [[ ! "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" =~ ^https?://[^[:space:]]+/internal/(memmy-)?release-notes/draft$ ]]; then - echo "::warning title=Doc Agent draft URL is invalid::The full release run will create a safe needs-review Draft. The URL must point to /internal/memmy-release-notes/draft or /internal/release-notes/draft." >&2 - exit 0 + echo "::error title=Doc Agent draft URL is invalid::DOC_AGENT_RELEASE_NOTES_DRAFT_URL must point to /internal/memmy-release-notes/draft or /internal/release-notes/draft. Manual recovery: fix the Actions variable, then re-run smoke." >&2 + exit 1 fi mkdir -p release-assets @@ -208,34 +206,32 @@ jobs: case "$http_status" in 400|422) ;; 200) - echo "::warning title=Doc Agent smoke contract mismatch::The 106 endpoint accepted an invalid smoke payload; the full run will use a safe needs-review Draft until the endpoint contract is corrected." >&2 - exit 0 + echo "::error title=Doc Agent smoke contract mismatch::The 106 draft endpoint accepted an intentionally invalid smoke payload. Manual recovery: verify the endpoint still rejects non-object evidence packets before release." >&2 + exit 1 ;; 401|403) - echo "::warning title=Doc Agent draft token rejected::The 106 endpoint rejected the token with HTTP $http_status; the full run will use a safe needs-review Draft." >&2 - exit 0 + echo "::error title=Doc Agent draft token rejected::The 106 draft endpoint rejected DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN with HTTP $http_status. Manual recovery: make the GitHub secret match the 106 RELEASE_NOTES_DRAFT_TOKEN, then re-run smoke." >&2 + exit 1 ;; 404) - echo "::warning title=Doc Agent draft endpoint disabled or wrong path::The 106 endpoint returned HTTP 404; the full run will use a safe needs-review Draft." >&2 - exit 0 + echo "::error title=Doc Agent draft endpoint disabled or wrong path::The 106 draft endpoint returned HTTP 404. Manual recovery: enable RELEASE_NOTES_DRAFT_ENABLED=true on 106 and verify the URL path, then re-run smoke." >&2 + exit 1 ;; 000|5*) - echo "::warning title=Doc Agent draft endpoint unavailable::The 106 endpoint was unreachable or returned HTTP $http_status; the full run will use a safe needs-review Draft." >&2 - exit 0 + echo "::error title=Doc Agent draft endpoint unavailable::The 106 draft endpoint was unreachable or returned HTTP $http_status. Manual recovery: verify doc-agent.service health and network access, then re-run smoke." >&2 + exit 1 ;; *) - echo "::warning title=Unexpected Doc Agent draft endpoint status::The 106 endpoint returned HTTP $http_status; the full run will use a safe needs-review Draft." >&2 - exit 0 + echo "::error title=Unexpected Doc Agent draft endpoint status::The 106 draft endpoint returned HTTP $http_status. Manual recovery: inspect the 106 logs and endpoint configuration, then re-run smoke." >&2 + exit 1 ;; esac if ! jq -e 'type == "object" and (has("detail") or has("error") or has("message") or has("warnings"))' "$response_file" >/dev/null; then - echo "::warning title=Doc Agent smoke response contract mismatch::The full run will use a safe needs-review Draft because validation failures are not returning an actionable JSON object." >&2 - exit 0 + echo "::error title=Doc Agent smoke response contract mismatch::Validation failures must return a JSON object with detail/error/message/warnings so release failures are actionable." >&2 + exit 1 fi - echo "available=true" >> "$GITHUB_OUTPUT" - { echo "## Doc Agent draft endpoint preflight" echo @@ -441,93 +437,33 @@ jobs: PREVIOUS_TAG: ${{ steps.previous.outputs.previous_tag }} DOC_AGENT_RELEASE_NOTES_DRAFT_URL: ${{ vars.DOC_AGENT_RELEASE_NOTES_DRAFT_URL || secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_URL }} DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} - DOC_AGENT_PREFLIGHT_AVAILABLE: ${{ steps.doc_agent.outputs.available }} run: | set -euo pipefail notes="release-assets/RELEASE_NOTES.md" manual_notes=".github/release-notes/$TAG.md" manual_object="${TARGET_SHA}:${manual_notes}" - reviewed_notes="release-assets/REVIEWED_RELEASE_NOTES.md" - manual_present="false" notes_source="unknown" needs_review="false" : > "$notes" - : > "$reviewed_notes" jq -n '{source: "unknown", needs_review: true, warnings: ["release notes source has not been selected yet"]}' > release-assets/RELEASE_NOTES_SOURCE.json jq -n '{ok: false, needs_review: true, warnings: ["release notes quality report has not been generated yet"]}' > release-assets/QUALITY_REPORT.json - write_safe_fallback_body() { - local reason="$1" - local allow_manual="${2:-true}" - needs_review="true" - if [[ "$manual_present" == "true" && "$allow_manual" == "true" ]]; then - cp "$reviewed_notes" "$notes" - notes_source="manual-needs-review-fallback" - else - notes_source="safe-needs-review-fallback" - cat > "$notes" < **Needs review:** Doc Agent could not produce validated public wording. Review the release evidence and replace this placeholder before publishing. - - ## Release review - - - The release target and installer assets passed the workflow's deterministic checks. - - Public release wording still requires human review. - EOF - fi + if git cat-file -e "$manual_object" 2>/dev/null; then + git show "$manual_object" > "$notes" + notes_source="manual" jq -n \ --arg source "$notes_source" \ - --arg reason "$reason" \ --arg file "$manual_notes" \ - --argjson manualPresent "$manual_present" \ - '{ - source: $source, - needs_review: true, - fallback_reason: $reason, - manual_file: (if $manualPresent then $file else null end), - warnings: [$reason] - }' > release-assets/RELEASE_NOTES_SOURCE.json + '{source: $source, manual_file: $file, needs_review: false, warnings: []}' \ + > release-assets/RELEASE_NOTES_SOURCE.json jq -n \ --arg source "$notes_source" \ - --arg reason "$reason" \ - '{ - ok: false, - needs_review: true, - source: $source, - fallback_reason: $reason, - warnings: [$reason], - recovery: "safe_needs_review_draft" - }' > release-assets/QUALITY_REPORT.json - } - - append_release_assets() { - cat >> "$notes" </dev/null; then - git show "$manual_object" > "$reviewed_notes" - manual_present="true" - fi - - cp release-assets/COMPARE.json release-assets/DRAFT_COMPARE.json - cp release-assets/PULL_REQUESTS.json release-assets/DRAFT_PULL_REQUESTS.json + --arg file "$manual_notes" \ + '{ok: true, needs_review: false, source: $source, manual_file: $file, warnings: []}' \ + > release-assets/QUALITY_REPORT.json + else + cp release-assets/COMPARE.json release-assets/DRAFT_COMPARE.json + cp release-assets/PULL_REQUESTS.json release-assets/DRAFT_PULL_REQUESTS.json total_commits="$(jq -r '.total_commits' release-assets/DRAFT_COMPARE.json)" received_commits="$(jq -r '.commits | length' release-assets/DRAFT_COMPARE.json)" compare_head="$(jq -r '.head_commit.sha // empty' release-assets/DRAFT_COMPARE.json)" @@ -622,14 +558,11 @@ jobs: --arg memoryCliVersion "$memory_cli_version" \ --arg agentVersion "$agent_version" \ --arg desktopVersion "$desktop_version" \ - --arg manualPath "$manual_notes" \ - --argjson manualPresent "$manual_present" \ - --rawfile reviewedMarkdown "$reviewed_notes" \ --slurpfile compare release-assets/DRAFT_COMPARE.json \ --slurpfile pullRequests release-assets/DRAFT_PULL_REQUESTS.json \ --slurpfile artifacts release-assets/ARTIFACTS.json \ --slurpfile styleExamples release-assets/MEMMY_RELEASE_STYLE_EXAMPLES.json \ - '({ + '{ source_id: $sourceId, sourceId: $sourceId, repository: $repository, @@ -674,7 +607,6 @@ jobs: candidate_count: 3, require_source_refs: true, require_bilingual_output: true, - public_release_language: "en", require_surfaces: true, max_items: 10, max_added_items: 5, @@ -686,23 +618,16 @@ jobs: release_context: { release_kind: "memmy_official_desktop_agent_memory_cli", public_release_body: "github_draft_release_notes", - public_release_language: "en", docs_product_extraction: "release_published_revalidated_by_106", manual_release_notes_file: ".github/release-notes/vX.Y.Z.md is optional" } - } + if $manualPresent then { - reviewed_release_notes: { - path: $manualPath, - public_language: "en", - markdown: $reviewedMarkdown - } - } else {} end)' > release-assets/DOC_AGENT_RELEASE_NOTES_REQUEST.json + }' > release-assets/DOC_AGENT_RELEASE_NOTES_REQUEST.json + + if [[ -z "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" || -z "$DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" ]]; then + echo "::error title=Doc Agent draft configuration missing::Full preflight requires DOC_AGENT_RELEASE_NOTES_DRAFT_URL and DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN. Manual recovery: configure them and run smoke before full preflight." >&2 + exit 1 + fi - response_usable="false" - fallback_reason="" - if [[ "$DOC_AGENT_PREFLIGHT_AVAILABLE" != "true" ]]; then - fallback_reason="Doc Agent preflight was unavailable; public wording requires review" - else response_status="$(curl --silent --show-error --location --retry 3 --retry-all-errors \ --connect-timeout 10 --max-time 180 \ --header "Content-Type: application/json" \ @@ -711,40 +636,32 @@ jobs: --output release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ --write-out '%{http_code}' \ "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" || true)" - if [[ "$response_status" != "200" ]]; then - fallback_reason="Doc Agent generation returned HTTP $response_status" - elif ! jq -e 'type == "object" and ((.release_notes_md // .release_notes_markdown // "") | length) > 0 and (.quality_report | type == "object")' \ + echo "::error title=Doc Agent draft generation failed::The 106 draft endpoint returned HTTP $response_status. Manual recovery: inspect release-assets/DOC_AGENT_RELEASE_NOTES_REQUEST.json, 106 logs, token/URL config, and LLM settings; do not fall back silently for a real Memmy release." >&2 + exit 1 + fi + if ! jq -e 'type == "object" and ((.release_notes_md // .release_notes_markdown // "") | length) > 0' \ + release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then + echo "::error title=Doc Agent returned invalid release notes::The response must include release_notes_md or release_notes_markdown. Manual recovery: fix the 106 draft endpoint contract before retrying." >&2 + exit 1 + fi + if ! jq -e '(.source // "doc-agent") == "doc-agent" and (.quality_report | type == "object")' \ release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then - fallback_reason="Doc Agent returned an invalid response contract" - elif ! jq -e ' - (.source // "") as $source - | ($source == "doc-agent" - or $source == "doc-agent-manual-regeneration" - or $source == "manual-reviewed-by-doc-agent" - or $source == "manual-repaired-by-doc-agent") - ' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then - fallback_reason="Doc Agent returned an unsupported release-notes source" - elif ! jq -e --argjson manualPresent "$manual_present" ' - (.source // "") as $source - | (.quality_report.candidate_selection.requested_candidate_count // .candidate_selection.requested_candidate_count // 0) as $requested - | if ($source == "manual-reviewed-by-doc-agent" or $source == "manual-repaired-by-doc-agent") - then ($manualPresent and $requested == 0 and (.manual_repair.llm_regenerated // true) == false) - elif $source == "doc-agent-manual-regeneration" - then ($manualPresent and $requested >= 3 and (.manual_repair.llm_regenerated // false) == true) - else ($source == "doc-agent" and $requested >= 3) - end - ' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then - fallback_reason="Doc Agent response did not prove the required validation or candidate repair path" - elif jq -e '(.needs_review // .quality_report.needs_review // false) == true or (.quality_report.ok // .ok // false) != true' \ + echo "::error title=Doc Agent quality report missing::The response must include source=doc-agent and a quality_report object. Manual recovery: deploy the corrected 106 code before retrying." >&2 + exit 1 + fi + if ! jq -e '(.quality_report.candidate_selection.requested_candidate_count // .candidate_selection.requested_candidate_count // 0) >= 3' \ release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then - fallback_reason="Doc Agent exhausted automatic wording repair without a publishable result" - else - response_usable="true" + echo "::error title=Doc Agent candidate selection missing::The 106 response must prove three-candidate generation/scoring for Memmy. Manual recovery: verify the 106 Memmy draft endpoint is running the v2 renderer." >&2 + exit 1 + fi + if jq -e '(.needs_review // .quality_report.needs_review // false) == true or (.quality_report.ok // .ok // false) != true' \ + release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then + echo "::error title=Doc Agent release notes need review::The generated release notes did not pass quality gates. Manual recovery: inspect QUALITY_REPORT.json, fix evidence/style/LLM output, or add .github/release-notes/$TAG.md as a reviewed manual override." >&2 + jq '.quality_report // .' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json > release-assets/QUALITY_REPORT.json || true + exit 1 fi - fi - if [[ "$response_usable" == "true" ]]; then jq -r '.release_notes_md // .release_notes_markdown' \ release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json > "$notes" jq '{ @@ -753,7 +670,6 @@ jobs: needs_review: (.needs_review // .quality_report.needs_review // false), confidence: (.confidence // .quality_report.confidence // "medium"), candidate_selection: (.quality_report.candidate_selection // .candidate_selection // {}), - manual_repair: (.manual_repair // .quality_report.manual_repair // null), warnings: (.quality_report.warnings // .warnings // []) }' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ > release-assets/RELEASE_NOTES_SOURCE.json @@ -764,51 +680,46 @@ jobs: warnings: (.warnings // []), coverage: (.coverage // {}), candidate_selection: (.candidate_selection // {}), - attempts: (.attempts // []), - manual_repair: (.manual_repair // null) + attempts: (.attempts // []) }' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ > release-assets/QUALITY_REPORT.json - notes_source="$(jq -r '.source' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json)" - needs_review="false" - else - echo "::warning title=Release notes require review::$fallback_reason. The workflow will continue with a safe Draft and will not publish it automatically." >&2 - write_safe_fallback_body "$fallback_reason" - fi - - if [[ ! -s "$notes" ]]; then - echo "::warning title=Release notes body was empty::The workflow replaced the empty body with a safe needs-review Draft." >&2 - write_safe_fallback_body "Doc Agent produced an empty public body" "false" - fi + notes_source="doc-agent" + needs_review="$(jq -r '.needs_review // .quality_report.needs_review // false' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json)" - append_release_assets - - sanitized_notes="${notes}.sanitized" - if ! node scripts/sanitize-release-notes.mjs "$notes" "$sanitized_notes" --language en; then - echo "::warning title=Release notes sanitization repaired with safe fallback::The final body still contained unsafe mixed-language or malformed metadata after upstream repair. It was replaced with an English needs-review Draft instead of stopping the release workflow." >&2 - write_safe_fallback_body "Final public-language validation rejected the repaired wording" "false" - append_release_assets - if ! node scripts/sanitize-release-notes.mjs "$notes" "$sanitized_notes" --language en; then - echo "::error title=Safe release-notes fallback failed::The fixed English fallback itself failed deterministic validation. This indicates a workflow implementation error, so Draft creation was stopped." >&2 + if [[ ! -s "$notes" ]]; then + echo "::error title=Release notes generation produced an empty body::No release notes were produced. Manual recovery: inspect the Doc Agent response or add .github/release-notes/$TAG.md as a reviewed manual override." >&2 exit 1 fi fi - mv "$sanitized_notes" "$notes" - - source_report="release-assets/RELEASE_NOTES_SOURCE.json" - source_report_tmp="${source_report}.tmp" - jq '. + { - public_release_language: "en", - language_validation: {ok: true, visible_body: "english_only"} - }' "$source_report" > "$source_report_tmp" - mv "$source_report_tmp" "$source_report" - - quality_report="release-assets/QUALITY_REPORT.json" - quality_report_tmp="${quality_report}.tmp" - jq '. + { - public_release_language: "en", - language_validation: {ok: true, visible_body: "english_only"} - }' "$quality_report" > "$quality_report_tmp" - mv "$quality_report_tmp" "$quality_report" + + cat >> "$notes" < + + EOF - name: Build auditable release evidence if: ${{ steps.release.outputs.preflight_level == 'full' }} diff --git a/.github/workflows/linux-cli-installer.yml b/.github/workflows/linux-cli-installer.yml index 49ad5295e..0232031c2 100644 --- a/.github/workflows/linux-cli-installer.yml +++ b/.github/workflows/linux-cli-installer.yml @@ -13,6 +13,9 @@ on: - "Migrations/**" - "App/backend/**" - "tests/linux-cli-packaging.test.mjs" + - "tests/linux-open-computer-use-bundle.test.mjs" + - "tests/linux-computer-use-deps.test.mjs" + - "scripts/internal/shared/check-open-computer-use.mjs" - "package.json" - "package-lock.json" push: @@ -26,6 +29,9 @@ on: - "Migrations/**" - "App/backend/**" - "tests/linux-cli-packaging.test.mjs" + - "tests/linux-open-computer-use-bundle.test.mjs" + - "tests/linux-computer-use-deps.test.mjs" + - "scripts/internal/shared/check-open-computer-use.mjs" - "package.json" - "package-lock.json" workflow_dispatch: @@ -74,10 +80,15 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" - name: Test Linux launcher and installer contracts + env: + # Release packaging provisions the Office rendering executables; they are + # unavailable to pull request and branch builds. + MEMMY_LINUX_CLI_ALLOW_MISSING_OFFICE_PAYLOAD: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && '1' || '0' }} run: npm run test:linux-cli - name: Build Linux CLI release assets env: VERSION: ${{ steps.version.outputs.version }} + MEMMY_LINUX_CLI_ALLOW_MISSING_OFFICE_PAYLOAD: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && '1' || '0' }} run: bash scripts/internal/linux/build-cli-archive.sh --version "$VERSION" --output release-assets - uses: actions/upload-artifact@v4 with: @@ -100,6 +111,10 @@ jobs: architecture: arm64 runs-on: ${{ matrix.runner }} steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'release' && format('refs/tags/{0}', github.event.release.tag_name) || github.sha }} + persist-credentials: false - uses: actions/download-artifact@v4 with: name: memmy-linux-cli-assets @@ -125,6 +140,10 @@ jobs: export MEMMY_RELEASE_BASE_URL="file://$PWD/release-assets" bash release-assets/install.sh bash release-assets/install.sh + ocu_arch="$EXPECTED_ARCH" + [[ "$ocu_arch" != "x64" ]] || ocu_arch="amd64" + node scripts/internal/shared/check-open-computer-use.mjs \ + "$MEMMY_INSTALL_ROOT/current/App/memmy-agent/node_modules/open-computer-use/dist/linux/$ocu_arch/open-computer-use" "$MEMMY_BIN_DIR/memmy" --version "$MEMMY_BIN_DIR/memmy" --help >/dev/null "$MEMMY_BIN_DIR/memmy-memory" health >/dev/null diff --git a/.github/workflows/memory-release.yml b/.github/workflows/memory-release.yml index b4a9cf02e..03d5ec56f 100644 --- a/.github/workflows/memory-release.yml +++ b/.github/workflows/memory-release.yml @@ -6,7 +6,7 @@ on: version: description: Memory version (X.Y.Z) required: true - default: 2.1.2 + default: 2.1.3 push: tags: - "memory-v*" diff --git a/.gitignore b/.gitignore index 4418fbd9c..0c13ec2f6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ release/ App/shell/desktop/release/ +Mac软件打包/ memmy-memory-*.tgz .vite/ *.log @@ -21,5 +22,8 @@ sessions/ .env .env.* !.env.example +# Holds the Windows Computer History folder open until it has code: git does +# not track empty directories, and `.*` above would otherwise drop the marker. +!App/memmy-agent/src/tools/computer-history/win/.gitkeep App/backend/src/adapters/outbound/skill-writer/workspace-bridge/memmy-workspace-bridge.mjs Memory/src/agent-source/integration/workspace-bridge/memmy-workspace-bridge.mjs diff --git a/AgentSourceCore/src/codex-source-turn.test.ts b/AgentSourceCore/src/codex-source-turn.test.ts new file mode 100644 index 000000000..70b9c28c8 --- /dev/null +++ b/AgentSourceCore/src/codex-source-turn.test.ts @@ -0,0 +1,134 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readCodexRollout, readCodexSourceTurn, sourceTurnFailureReason, sourceTurnFromMessages, buildSourceTurnRequest, orderedTurns, type ConversationMessage } from "./index.js"; + +const dirs: string[] = []; +afterEach(() => { for (const path of dirs.splice(0)) rmSync(path, { recursive: true, force: true }); }); +const time = "2026-09-09T10:00:00.000Z"; +const event = (type: string, payload: Record) => ({ type, timestamp: time, payload }); +const message = (role: string, text: string) => event("response_item", { type: "message", role, content: [{ text }] }); +function fixture(records: unknown[]) { + const dir = mkdtempSync(join(tmpdir(), "source-turn-")); dirs.push(dir); + const path = join(dir, "rollout-file.jsonl"); writeFileSync(path, records.map(r => JSON.stringify(r)).join("\n") + "\n"); return path; +} +async function collect(values: AsyncIterable): Promise { const all: T[] = []; for await (const value of values) all.push(value); return all; } +async function read(records: unknown[]) { return collect(readCodexRollout(fixture(records))); } +const prefix = [event("session_meta", { id: "file-artifact", session_id: "conversation", cwd: "/project" }), event("event_msg", { type: "task_started", turn_id: "turn-1" }), message("user", "Fix the issue")]; +const end = [message("assistant", "Done"), event("event_msg", { type: "task_complete", turn_id: "turn-1" })]; + +describe("Codex native source turns", () => { + it("retains steering in one native turn and pairs missing/reversed results only by call id", async () => { + const messages = await read([...prefix, + event("response_item", { type: "function_call", call_id: "a", name: "read", arguments: { path: "a" } }), + message("user", "Also run tests"), + event("response_item", { type: "custom_tool_call", call_id: "b", name: "test", input: "npm test" }), + event("response_item", { type: "custom_tool_call_output", call_id: "b", output: "FAILED", status: "failed" }), + event("response_item", { type: "web_search_call", id: "web", action: { query: "fix" }, status: "completed" }), ...end]); + const turn = sourceTurnFromMessages(messages); + expect(turn).toMatchObject({ conversationId: "conversation", turnId: "turn-1", query: "Fix the issue\n\nAlso run tests", answer: "Done", completionEvidence: "task_complete:turn-1" }); + expect(turn?.toolCalls).toEqual([ + expect.objectContaining({ id: "a", name: "read", input: { path: "a" } }), + expect.objectContaining({ id: "b", name: "test", input: "npm test", output: "FAILED", status: "failed", success: false }), + expect.objectContaining({ id: "web", name: "web_search", input: { query: "fix" }, status: "completed" }) + ]); + expect(turn?.toolCalls[0]).not.toHaveProperty("output"); + const staged = messages.map(m => ({ ...m, sourceId: "codex", workspacePath: null, gitRoot: null })) as ConversationMessage[]; + expect(await collect(orderedTurns((async function* () { yield* staged; })()))).toHaveLength(1); + expect(buildSourceTurnRequest(turn!, "hook").sourceTurn).toEqual(buildSourceTurnRequest(turn!, "agent_source_scan").sourceTurn); + }); + + it("does not create a canonical turn without native identity, completion, or matching completion", async () => { + for (const records of [[message("user", "Q"), message("assistant", "A")], [...prefix, message("assistant", "still running")], [...prefix, ...end.slice(0, 1), event("event_msg", { type: "task_complete", turn_id: "other" })]]) { + const messages = await read(records); + expect(sourceTurnFromMessages(messages)).toBeNull(); + expect(messages.at(-1)?.rawMeta.sourceTurnState).not.toBe("complete"); + } + }); + + it("never matches a result to another turn and keeps unresolved turns for scan retry", async () => { + const messages = await read([...prefix, event("response_item", { type: "function_call", call_id: "same", name: "old", arguments: "x" }), ...end, + event("event_msg", { type: "task_started", turn_id: "turn-2" }), message("user", "Second"), event("response_item", { type: "function_call_output", call_id: "same", output: "not for old" })]); + const first = sourceTurnFromMessages(messages.filter(m => m.rawMeta.sourceTurnId === "turn-1")); + expect(first?.toolCalls[0]).not.toHaveProperty("output"); + const staged = messages.map(m => ({ ...m, sourceId: "codex", workspacePath: null, gitRoot: null })) as ConversationMessage[]; + const turns = await collect(orderedTurns((async function* () { yield* staged; })())); + expect(turns).toHaveLength(2); expect(sourceTurnFromMessages(turns[1]!.messages)).toBeNull(); + }); + + it("applies the existing redactor equally to canonical text and structured tool values", async () => { + const token = `sk-${"fixture".repeat(8)}`; + const messages = await read([...prefix, event("response_item", { type: "custom_tool_call", call_id: "a", name: "write", input: { token } }), event("response_item", { type: "custom_tool_call_output", call_id: "a", output: { token } }), ...end]); + const turn = sourceTurnFromMessages(messages)!; + expect(JSON.stringify(turn)).not.toContain(token); + expect(turn.toolCalls[0]?.input).toEqual({ token: "[REDACTED:openai_api_key]" }); + }); + it("keeps the native completion timestamp visible to incremental scanning", async () => { + const completedAt = "2026-09-09T10:05:00.000Z"; + const messages = await read([...prefix, message("assistant", "Done"), { ...event("event_msg", { type: "task_complete", turn_id: "turn-1" }), timestamp: completedAt }]); + expect(messages.at(-1)).toMatchObject({ role: "system", createdAt: completedAt, rawMeta: { sourceTurnState: "complete" } }); + expect(sourceTurnFromMessages(messages)?.completedAt).toBe(completedAt); + }); + + it("accepts duplicate evidence for the same canonical turn but does not merge conflicting content", async () => { + const messages = await read([...prefix, ...end]); + const turn = sourceTurnFromMessages(messages)!; + expect(sourceTurnFromMessages([...messages, ...messages])).toEqual(turn); + expect(sourceTurnFromMessages([...messages, { rawMeta: { sourceTurn: { ...turn, answer: "different" } } }])).toBeNull(); + }); + + it("retains numeric record order even when many events have the same timestamp", async () => { + const messages = await read([...prefix, ...Array.from({ length: 15 }, (_, i) => message("assistant", `progress ${i}`)), ...end]); + expect([...messages].sort((a, b) => a.messageId.localeCompare(b.messageId))).toEqual(messages); + }); + + it("resolves an initial user message followed by turn_context without inventing an ID", async () => { + const messages = await read([prefix[0], message("user", "Question"), event("turn_context", { turn_id: "turn-1" }), ...end]); + expect(sourceTurnFromMessages(messages)).toMatchObject({ turnId: "turn-1", query: "Question" }); + }); + + it("keeps a turn pending when a malformed record might hide a tool call or result", async () => { + const path = fixture([...prefix, ...end]); + const records = [...prefix, ...end].map(record => JSON.stringify(record)); + records.splice(3, 0, '{"type":"response_item","payload":'); + writeFileSync(path, records.join("\n") + "\n"); + const messages = await collect(readCodexRollout(path)); + expect(sourceTurnFromMessages(messages)).toBeNull(); + expect(messages.at(-1)?.rawMeta.sourceTurnReason).toBe("source_record_invalid"); + }); + + it("does not assign one result to two calls with the same native call id", async () => { + const messages = await read([...prefix, + event("response_item", { type: "function_call", call_id: "a", name: "read", arguments: "first" }), + event("response_item", { type: "function_call", call_id: "a", name: "write", arguments: "second" }), + event("response_item", { type: "function_call_output", call_id: "a", output: "ambiguous" }), ...end]); + expect(sourceTurnFromMessages(messages)?.toolCalls.every(call => call.output === undefined)).toBe(true); + }); + + it("keeps conflicting or incomplete repeated native turns pending in both channels", async () => { + const completed = [...prefix, ...end]; + for (const tail of [[...prefix, message("assistant", "changed"), end[1]], [...prefix, message("assistant", "still working")]]) { + const path = fixture([...completed, ...tail]); + expect(sourceTurnFromMessages(await collect(readCodexRollout(path)))).toBeNull(); + expect((await readCodexSourceTurn(path, { turnId: "turn-1", conversationId: "conversation", stop: true })).turn).toBeNull(); + } + }); + + it("does not emit startup or trailing system-only context as a pending memory turn", async () => { + const messages = await read([prefix[0], message("developer", "startup instructions"), ...prefix.slice(1), ...end, message("developer", "context for next turn")]); + expect(sourceTurnFromMessages(messages)).not.toBeNull(); + expect(messages.every(message => message.rawMeta.sourceTurnState === "complete")).toBe(true); + expect(await read([prefix[0], message("system", "startup only")])).toEqual([]); + }); + + it("reports pending evidence before content conflicts and distinguishes conflicts from missing identity", async () => { + const messages = await read([...prefix, ...end]); + const turn = sourceTurnFromMessages(messages)!; + const conflicting = [...messages, { rawMeta: { sourceTurn: { ...turn, answer: "different content" } } }]; + expect(sourceTurnFailureReason(conflicting)).toBe("source_turn_content_conflict"); + expect(sourceTurnFailureReason([...conflicting, { rawMeta: { sourceTurnState: "turn_incomplete", sourceTurnReason: "source_record_invalid" } }])).toBe("source_record_invalid"); + expect(sourceTurnFailureReason([{ rawMeta: {} }])).toBe("identity_unresolved"); + }); + +}); diff --git a/AgentSourceCore/src/codex-source-turn.ts b/AgentSourceCore/src/codex-source-turn.ts new file mode 100644 index 000000000..751fd3947 --- /dev/null +++ b/AgentSourceCore/src/codex-source-turn.ts @@ -0,0 +1,284 @@ +import { basename } from "node:path"; +import { readJsonlObjects } from "./jsonl-lines.js"; +import { redactSecrets } from "./secret-redactor.js"; + +export interface SourceToolResult { + id?: string; + output?: unknown; + status?: string; + success?: boolean; + error?: unknown; +} +export interface SourceToolCall extends SourceToolResult { + name: string; + input?: unknown; +} +export interface SourceTurn { + source: "codex"; + conversationId: string; + turnId: string; + startedAt: string; + completedAt: string; + sequence: number; + completionEvidence: string; + query: string; + answer: string; + status: "succeeded" | "failed"; + toolCalls: SourceToolCall[]; + toolResults: SourceToolResult[]; + workspacePath?: string; +} +export interface RawCodexMessage { + messageId: string; + conversationId: string; + role: "user" | "assistant" | "tool" | "system"; + content: string; + createdAt: string; + ordinal: number; + rawMeta: Readonly>; +} + +/** Canonical turn is stored once, on the final staged message, including its native completion evidence. */ +export function sourceTurnFromMessages(messages: readonly { rawMeta: Readonly> }[]): SourceTurn | null { + if (messages.some(message => message.rawMeta.sourceTurnState && message.rawMeta.sourceTurnState !== "complete")) return null; + const turns = messages.map(message => message.rawMeta.sourceTurn).filter(isRecord); + if (turns.length === 0) return null; + if (turns.some(turn => canonicalTurnContent(turn) !== canonicalTurnContent(turns[0]!))) return null; + const turn = turns[0]!; + if (turn.source !== "codex" || !text(turn.conversationId) || !text(turn.turnId) || !text(turn.completionEvidence)) return null; + return turn as unknown as SourceTurn; +} + +/** Preserve the reason a staged native turn cannot be submitted, including conflicting complete evidence. */ +export function sourceTurnFailureReason(messages: readonly { rawMeta: Readonly> }[]): string { + for (const message of messages) { + const state = text(message.rawMeta.sourceTurnState); + if (state && state !== "complete") return text(message.rawMeta.sourceTurnReason) || state; + } + const turns = messages.map(message => message.rawMeta.sourceTurn).filter(isRecord); + if (turns.length > 1 && turns.some(turn => canonicalTurnContent(turn) !== canonicalTurnContent(turns[0]!))) { + return "source_turn_content_conflict"; + } + return "identity_unresolved"; +} + +export function buildSourceTurnRequest(turn: SourceTurn, channel: "hook" | "agent_source_scan", profileId = "default") { + return { + sourceTurn: { + source: turn.source, profileId, conversationId: turn.conversationId, turnId: turn.turnId, + startedAt: turn.startedAt, completedAt: turn.completedAt, sequence: turn.sequence, completionEvidence: turn.completionEvidence + }, + source: turn.source, profileId, channel, query: turn.query, answer: turn.answer, + status: turn.status, toolCalls: turn.toolCalls, toolResults: turn.toolResults, + workspacePath: turn.workspacePath + }; +} + +/** Read one native turn at a time; neither assistant text alone nor EOF proves completion. */ +export async function* readCodexRollout( + filePath: string, + signal?: AbortSignal, + stopEvidence?: { conversationId?: string; turnId: string } +): AsyncIterable { + const fileId = rolloutFileId(filePath); + let conversationId = ""; + let workspacePath: string | undefined; + let current: RawCodexMessage[] = []; + let turnId = ""; + let startedAt = ""; + let sequence = 0; + let lineNumber = 0; + let invalidReason = ""; + let toolCalls: SourceToolCall[] = []; + let toolResults: SourceToolResult[] = []; + const toolNames = new Map(); + + function finish(completedAt = "", completionId = "", status: "succeeded" | "failed" = "succeeded", completionKind = "task_complete"): RawCodexMessage[] { + if (current.length === 0) return []; + if (current.every(message => message.role === "system")) { + current = []; invalidReason = ""; + return []; + } + const query = current.filter(message => message.role === "user").map(message => message.content).join("\n\n"); + const answer = current.filter(message => message.role === "assistant").map(message => message.content).join("\n\n"); + let reason = invalidReason; + if (!conversationId || !turnId) reason ||= "identity_unresolved"; + else if (!completedAt || completionId !== turnId) reason ||= "turn_incomplete"; + else if (!startedAt || !Number.isFinite(Date.parse(startedAt)) || !Number.isFinite(Date.parse(completedAt))) reason ||= "timestamp_unresolved"; + else if (!query.trim() || !answer.trim()) reason ||= "turn_content_incomplete"; + const finalMessage = [...current].reverse().find(message => message.role !== "system"); + const hasFinalAnswer = finalMessage?.role === "assistant" && finalMessage.rawMeta.sourcePhase === "final_answer"; + const canonicalCompletedAt = hasFinalAnswer ? finalMessage.createdAt : completedAt; + const completionEvidence = hasFinalAnswer + ? `final_answer:${text(finalMessage.rawMeta.sourceRecordId) || turnId}` + : `task_complete:${turnId}`; + if (completedAt && completionKind === "task_complete") { + current.push({ messageId: `${fileId}:${String(lineNumber).padStart(12, "0")}`, conversationId: conversationId || fileId, + role: "system", content: "Codex task_complete", createdAt: completedAt, ordinal: lineNumber, + rawMeta: { sourceFile: filePath, sourceTurnId: turnId || undefined, sourceTurnSequence: sequence, sourceTurnStartedAt: startedAt || undefined } + }); + } + const output = current.map(message => ({ ...message, rawMeta: { ...message.rawMeta, sourceTurnId: turnId || undefined, sourceTurnState: reason || "complete", sourceTurnReason: reason || undefined } })); + if (!reason) { + const resultsById = new Map(); + const duplicateIds = new Set(); + for (const result of toolResults) { + if (!result.id) continue; + if (resultsById.has(result.id)) duplicateIds.add(result.id); + else resultsById.set(result.id, result); + } + const callCounts = new Map(); + for (const call of toolCalls) { + if (call.id) callCounts.set(call.id, (callCounts.get(call.id) ?? 0) + 1); + } + const paired = toolCalls.map(call => { + const result = call.id && callCounts.get(call.id) === 1 && !duplicateIds.has(call.id) ? resultsById.get(call.id) : undefined; + return result ? { ...call, ...result, name: call.name, input: call.input } : call; + }); + const turn: SourceTurn = { + source: "codex", conversationId, turnId, startedAt, completedAt: canonicalCompletedAt, sequence, + completionEvidence, query: redactSecrets(query), answer: redactSecrets(answer), status, + toolCalls: paired.map(redactCall), toolResults: toolResults.map(redactResult), workspacePath + }; + const last = output[output.length - 1]!; + last.rawMeta = { ...last.rawMeta, sourceTurn: turn } as typeof last.rawMeta; + } + current = []; toolCalls = []; toolResults = []; toolNames.clear(); invalidReason = ""; + return output; + } + + for await (const record of readJsonlObjects(filePath, signal, reason => { invalidReason = reason; })) { + lineNumber += 1; + const payload = isRecord(record.payload) ? record.payload : {}; + const timestamp = iso(record.timestamp); + if (record.type === "session_meta") { + const nativeId = text(payload.session_id) || text(payload.id); + if (conversationId && nativeId && nativeId !== conversationId) { + invalidReason = "identity_conflict"; + yield* finish(); turnId = ""; + } + conversationId = nativeId || conversationId; + workspacePath = text(payload.cwd) || workspacePath; + continue; + } + if (record.type === "turn_context" || (record.type === "event_msg" && payload.type === "task_started")) { + const nextId = text(payload.turn_id); + if (nextId && nextId !== turnId) { + if (turnId || current.some(message => message.role === "assistant" || message.role === "tool")) yield* finish(); + turnId = nextId; + startedAt = current.find(message => message.role === "user")?.createdAt || timestamp; + sequence = lineNumber; + } + workspacePath = text(payload.cwd) || workspacePath; + continue; + } + if (record.type === "event_msg" && payload.type === "task_complete") { + const completeId = text(payload.turn_id); + if (completeId !== turnId) invalidReason = "identity_conflict"; + yield* finish(timestamp, completeId, payload.status === "failed" ? "failed" : "succeeded"); + turnId = ""; startedAt = ""; + continue; + } + if (record.type === "event_msg" && (payload.type === "turn_aborted" || payload.type === "task_aborted")) { + invalidReason = "turn_cancelled"; yield* finish(); turnId = ""; startedAt = ""; continue; + } + if (record.type !== "response_item") continue; + if (text(payload.turn_id) && text(payload.turn_id) !== turnId) invalidReason = "identity_conflict"; + let role: RawCodexMessage["role"]; + let content: string; + if (payload.type === "message") { + const rawRole = payload.role; + if (rawRole !== "user" && rawRole !== "assistant" && rawRole !== "developer" && rawRole !== "system") continue; + role = rawRole === "developer" ? "system" : rawRole; + content = Array.isArray(payload.content) ? payload.content.map(item => isRecord(item) ? text(item.text) : "").filter(Boolean).join("\n") : ""; + } else { + role = "tool"; + const id = text(payload.call_id) || text(payload.id) || undefined; + const status = text(payload.status) || undefined; + const success = toolSuccess(payload); + const error = payload.error; + if (payload.type === "function_call" || payload.type === "custom_tool_call" || payload.type === "web_search_call") { + const name = payload.type === "web_search_call" ? "web_search" : text(payload.name) || "tool"; + const input = payload.type === "web_search_call" ? payload.action : payload.arguments ?? payload.input; + const call = compact({ id, name, status, success, error, input, output: payload.output ?? payload.result }) as unknown as SourceToolCall; + toolCalls.push(call); if (id) toolNames.set(id, name); + content = renderTool(call); + } else if (payload.type === "function_call_output" || payload.type === "custom_tool_call_output") { + const result = compact({ id, output: payload.output ?? payload.result, status, success, error }) as SourceToolResult; + toolResults.push(result); content = renderTool({ ...result, name: id ? toolNames.get(id) ?? "tool" : "tool" }); + } else continue; + } + if (!content) continue; + if (!timestamp && role !== "system") invalidReason = "timestamp_unresolved"; + current.push({ messageId: `${fileId}:${String(lineNumber).padStart(12, "0")}`, conversationId: conversationId || fileId, role, content, createdAt: timestamp || new Date(0).toISOString(), ordinal: lineNumber, + rawMeta: { sourceFile: filePath, sourceRecordId: text(payload.id) || undefined, sourcePhase: text(payload.phase) || undefined, sourceTurnId: turnId || undefined, sourceTurnSequence: sequence, sourceTurnStartedAt: startedAt || undefined } }); + } + const lastResponse = [...current].reverse().find(message => message.role !== "system"); + const stopMatches = stopEvidence?.turnId === turnId && + (!stopEvidence.conversationId || stopEvidence.conversationId === conversationId); + if (stopMatches && lastResponse?.role === "assistant" && lastResponse.rawMeta.sourcePhase === "final_answer") { + yield* finish(lastResponse.createdAt, turnId, "succeeded", "stop"); + } else { + yield* finish(); + } +} + +/** Hook uses the exact same streaming parser as the scan adapter. */ +export async function readCodexSourceTurn(filePath: string, expected: { conversationId?: string; turnId?: string; stop?: boolean } = {}): Promise<{ turn: SourceTurn | null; reason?: string }> { + let latest: SourceTurn | null = null; + let observed: SourceTurn | null = null; + let conflict = false; + let unresolved = false; + let reason = "identity_unresolved"; + let latestTurnId: unknown; + const stopEvidence = expected.stop && expected.turnId + ? { conversationId: expected.conversationId, turnId: expected.turnId } + : undefined; + for await (const message of readCodexRollout(filePath, undefined, stopEvidence)) { + if (expected.conversationId && message.conversationId !== expected.conversationId) return { turn: null, reason: "identity_conflict" }; + if (expected.turnId && message.rawMeta.sourceTurnId !== expected.turnId) continue; + if (message.rawMeta.sourceTurnId !== latestTurnId) { + latest = null; observed = null; conflict = false; unresolved = false; + } + latestTurnId = message.rawMeta.sourceTurnId; + reason = text(message.rawMeta.sourceTurnReason) || "turn_incomplete"; + if (message.rawMeta.sourceTurnState !== "complete") { latest = null; unresolved = true; } + const turn = sourceTurnFromMessages([message]); + if (turn) { + if (observed && canonicalTurnContent(observed) !== canonicalTurnContent(turn)) conflict = true; + observed = turn; + latest = turn; + } + } + if (conflict) return { turn: null, reason: "source_turn_content_conflict" }; + if (unresolved) return { turn: null, reason }; + return latest ? { turn: latest } : { turn: null, reason }; +} + +function canonicalTurnContent(turn: Record | SourceTurn): string { + const { sequence: _sequence, ...content } = turn; + return JSON.stringify(content); +} + +function redactCall(call: SourceToolCall): SourceToolCall { return { ...call, name: redactSecrets(call.name), ...redactResult(call), ...(call.input !== undefined ? { input: redactValue(call.input) } : {}) }; } +function redactResult(result: SourceToolResult): SourceToolResult { return { ...result, ...(result.output !== undefined ? { output: redactValue(result.output) } : {}), ...(result.error !== undefined ? { error: redactValue(result.error) } : {}) }; } +function redactValue(value: unknown): unknown { + if (typeof value === "string") return redactSecrets(value); + if (Array.isArray(value)) return value.map(redactValue); + if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactValue(entry)])); + return value; +} +function toolSuccess(payload: Record): boolean | undefined { + if (typeof payload.success === "boolean") return payload.success; + if (typeof payload.is_error === "boolean") return !payload.is_error; + if ((payload.error !== undefined && payload.error !== null) || payload.status === "failed" || payload.status === "cancelled") return false; + if (payload.status === "completed" || payload.status === "succeeded") return true; + return undefined; +} +function renderTool(tool: SourceToolCall): string { return [`Tool: ${tool.name}`, tool.id ? `Call ID: ${tool.id}` : undefined, tool.status ? `Status: ${tool.status}` : undefined, tool.input !== undefined ? `Input:\n${format(tool.input)}` : undefined, tool.output !== undefined ? `Output:\n${format(tool.output)}` : undefined].filter(Boolean).join("\n\n"); } +function format(value: unknown): string { return typeof value === "string" ? value.trim() : JSON.stringify(value, null, 2); } +function compact(value: Record): Record { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); } +function text(value: unknown): string { return typeof value === "string" ? value : ""; } +function iso(value: unknown): string { const parsed = typeof value === "string" ? Date.parse(value) : NaN; return Number.isFinite(parsed) ? new Date(parsed).toISOString() : ""; } +function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function rolloutFileId(path: string): string { const name = basename(path).replace(/\.jsonl$/u, ""); return name.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu)?.[0] ?? name; } diff --git a/AgentSourceCore/src/index.test.ts b/AgentSourceCore/src/index.test.ts index 16cdccb42..b3eea8117 100644 --- a/AgentSourceCore/src/index.test.ts +++ b/AgentSourceCore/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { TURN_CONTENT_MAX_BYTES, conversationContentHash, orderedTurns, renderTurn, renderTurnClipped, type ConversationMessage } from "./index.js"; +import { TURN_CONTENT_MAX_BYTES, conversationContentHash, orderedTurns, renderTurnClipped, type ConversationMessage } from "./index.js"; const message = (id: string, role: ConversationMessage["role"], content: string, createdAt: string): ConversationMessage => ({ messageId: id, sourceId: "fixture", conversationId: "conversation", role, content, createdAt, @@ -45,29 +45,4 @@ describe("agent source core", () => { const messages = [message("u", "user", "hello", "2026-01-01T00:00:00Z"), message("a", "assistant", "world", "2026-01-01T00:00:01Z")]; expect(renderTurnClipped(messages, TURN_CONTENT_MAX_BYTES)).toBe("## user\n\nhello\n\n## assistant\n\nworld"); }); - - it.each([ - ["ASCII", "x".repeat(600_000)], - ["Chinese and emoji", "汉🙂".repeat(160_000)], - ["escaped control characters", "\u0001".repeat(400_000)], - ])("bounds both raw UTF-8 and JSON bytes for %s", (_label, text) => { - const messages = [message("u", "user", "request", "2026-01-01T00:00:00Z"), message("t", "tool", text, "2026-01-01T00:00:01Z")]; - const content = renderTurnClipped(messages); - expect(Buffer.byteLength(content)).toBeLessThanOrEqual(TURN_CONTENT_MAX_BYTES); - expect(Buffer.byteLength(JSON.stringify(content))).toBeLessThanOrEqual(1024 * 1024); - expect(content).not.toContain("\ufffd"); - const truncation = /\n\n\[\.\.\. truncated (\d+) bytes of tool output \.\.\.\]$/u.exec(content); - expect(truncation).not.toBeNull(); - expect(Number(truncation![1])).toBe(Buffer.byteLength(renderTurn(messages)) - Buffer.byteLength(content.slice(0, truncation!.index))); - }); - - it("preserves quoted text and newlines when both budgets allow the complete turn", () => { - const messages = [message("u", "user", '"\\\n'.repeat(150_000), "2026-01-01T00:00:00Z")]; - expect(renderTurnClipped(messages)).toBe(renderTurn(messages)); - }); - - it.each([0, 1, 8, 48])("includes the truncation marker inside a %i-byte budget", (maxBytes) => { - const content = renderTurnClipped([message("u", "user", "x".repeat(1000), "2026-01-01T00:00:00Z")], maxBytes); - expect(Buffer.byteLength(content)).toBeLessThanOrEqual(maxBytes); - }); }); diff --git a/AgentSourceCore/src/index.ts b/AgentSourceCore/src/index.ts index bfd369c19..b00d32933 100644 --- a/AgentSourceCore/src/index.ts +++ b/AgentSourceCore/src/index.ts @@ -1,3 +1,6 @@ +export * from "./codex-source-turn.js"; +export * from "./secret-redactor.js"; +export * from "./jsonl-lines.js"; import { createHash } from "node:crypto"; export interface ConversationMessage { @@ -157,19 +160,32 @@ export async function* orderedTurns(messages: AsyncIterable let turnIndex = 0; for await (const message of messages) { if (message.conversationId !== conversationId) { - if (isCompleteTurn(current)) yield { sourceId: current[0]!.sourceId, conversationId, turnIndex, messages: current }; + if (shouldEmitTurn(current)) yield { sourceId: current[0]!.sourceId, conversationId, turnIndex, messages: current }; current = []; conversationId = message.conversationId; turnIndex = 0; } - if (message.role === "user" && current.length > 0) { - if (isCompleteTurn(current)) yield { sourceId: current[0]!.sourceId, conversationId, turnIndex, messages: current }; + if (current.length > 0 && beginsNextTurn(current, message)) { + if (shouldEmitTurn(current)) yield { sourceId: current[0]!.sourceId, conversationId, turnIndex, messages: current }; turnIndex += 1; current = []; } current.push(message); } - if (isCompleteTurn(current)) yield { sourceId: current[0]!.sourceId, conversationId, turnIndex, messages: current }; + if (shouldEmitTurn(current)) yield { sourceId: current[0]!.sourceId, conversationId, turnIndex, messages: current }; +} + +function shouldEmitTurn(messages: readonly ConversationMessage[]): boolean { + return messages.length > 0 && (messages[0]!.sourceId === "codex" || isCompleteTurn(messages)); +} + +function beginsNextTurn(current: readonly ConversationMessage[], next: ConversationMessage): boolean { + if (next.sourceId === "codex") { + const currentId = current[0]!.rawMeta.sourceTurnId; + const nextId = next.rawMeta.sourceTurnId; + if (currentId || nextId) return currentId !== nextId; + } + return next.role === "user"; } export function isCompleteTurn(messages: readonly ConversationMessage[]): boolean { @@ -212,6 +228,10 @@ export function conversationContentHash(messages: Iterable) } export function stableTurnIdentity(turn: ImportedTurn): string { + const nativeId = turn.messages[0]?.rawMeta.sourceTurnId; + if (turn.sourceId === "codex") { + return `${turn.sourceId}::${turn.conversationId}::${typeof nativeId === "string" ? nativeId : turn.messages[0]?.messageId ?? "unresolved"}`; + } const firstUser = turn.messages.find((message) => message.role === "user"); if (!firstUser) throw new Error("turn is missing user message"); return `${turn.sourceId}::${turn.conversationId}::${firstUser.messageId}`; @@ -229,9 +249,8 @@ export function legacyTurnId(turn: ImportedTurn): string { return `${turn.sourceId}:${createHash("sha256").update(stableTurnIdentity(turn)).digest("hex").slice(0, 24)}`; } -/** Raw UTF-8 content limit; JSON escaping has a separate transport budget. */ +/** Leaves ample room for JSON escaping and the add-memory envelope. */ export const TURN_CONTENT_MAX_BYTES = 512 * 1024; -const TURN_CONTENT_MAX_JSON_BYTES = 1024 * 1024; /** * Renders a whole turn as one memory body. Agent-source scans deliberately keep @@ -243,31 +262,19 @@ const TURN_CONTENT_MAX_JSON_BYTES = 1024 * 1024; export function renderTurnClipped(messages: readonly ConversationMessage[], maxBytes = TURN_CONTENT_MAX_BYTES): string { const content = renderTurn(messages); const bytes = Buffer.byteLength(content); - if (bytes <= maxBytes && jsonContentBytes(content) + 2 <= TURN_CONTENT_MAX_JSON_BYTES) return content; - const marker = (omitted: number) => `\n\n[... truncated ${omitted} bytes of tool output ...]`; - // Reserving the largest possible omission count also bounds the final marker. - const reservedMarker = marker(bytes); - const markerBytes = Buffer.byteLength(reservedMarker); - const rawBudget = Math.max(0, maxBytes); - if (rawBudget <= markerBytes) return clipUtf8(reservedMarker, rawBudget, TURN_CONTENT_MAX_JSON_BYTES - 2); - const prefix = clipUtf8(content, rawBudget - markerBytes, TURN_CONTENT_MAX_JSON_BYTES - 2 - jsonContentBytes(reservedMarker)); - return `${prefix}${marker(bytes - Buffer.byteLength(prefix))}`; -} - -function jsonContentBytes(value: string): number { - return Buffer.byteLength(JSON.stringify(value)) - 2; + if (bytes <= maxBytes) return content; + const marker = `\n\n[... truncated ${bytes - maxBytes} bytes of tool output ...]`; + const budget = Math.max(0, maxBytes - Buffer.byteLength(marker)); + return `${clipUtf8(content, budget)}${marker}`; } -function clipUtf8(value: string, maxBytes: number, maxJsonBytes: number): string { +function clipUtf8(value: string, maxBytes: number): string { let bytes = 0; - let jsonBytes = 0; let end = 0; for (const character of value) { const characterBytes = Buffer.byteLength(character); - const characterJsonBytes = jsonContentBytes(character); - if (bytes + characterBytes > maxBytes || jsonBytes + characterJsonBytes > maxJsonBytes) break; + if (bytes + characterBytes > maxBytes) break; bytes += characterBytes; - jsonBytes += characterJsonBytes; end += character.length; } return value.slice(0, end); diff --git a/AgentSourceCore/src/jsonl-lines.ts b/AgentSourceCore/src/jsonl-lines.ts new file mode 100644 index 000000000..d02b5e216 --- /dev/null +++ b/AgentSourceCore/src/jsonl-lines.ts @@ -0,0 +1,101 @@ +/** Jsonl lines module. */ +import { createReadStream } from "node:fs"; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +/** + * Streams valid object rows from a JSONL file. + * Malformed and non-object rows are skipped without interrupting the stream. + * + * @param filePath JSONL file path. + * @param signal Optional abort signal. + * @returns The JSON objects parsed line by line. + */ +export async function* readJsonlObjects(filePath: string, signal?: AbortSignal, onInvalidRecord?: (reason: string) => void): AsyncIterable { + const stream = createReadStream(filePath); + const maxRecordBytes = 64 * 1024 * 1024; + let segments: Buffer[] = []; + let recordBytes = 0; + let overLimit = false; + const append = (segment: Buffer): void => { + if (overLimit || segment.length === 0) return; + recordBytes += segment.length; + if (recordBytes > maxRecordBytes) { + segments = []; + overLimit = true; + return; + } + segments.push(segment); + }; + const reset = (): void => { + segments = []; + recordBytes = 0; + overLimit = false; + }; + const parseSegments = (): JsonObject | null => { + if (overLimit) { + onInvalidRecord?.("source_record_over_limit"); + return null; + } + const line = segments.length === 1 ? segments[0]! : Buffer.concat(segments, recordBytes); + const text = line.toString("utf8").trim(); + if (!text) return null; + try { + const parsed = JSON.parse(text) as unknown; + if (isJsonObject(parsed)) return parsed; + onInvalidRecord?.("source_record_invalid"); + return null; + } catch { + onInvalidRecord?.("source_record_invalid"); + return null; + } + }; + + try { + for await (const chunk of stream) { + throwIfAborted(signal, filePath); + const buffer = chunk as Buffer; + let start = 0; + while (start <= buffer.length) { + const newline = buffer.indexOf(0x0a, start); + if (newline < 0) { + append(buffer.subarray(start)); + break; + } + append(buffer.subarray(start, newline)); + const parsed = parseSegments(); + reset(); + if (parsed) yield parsed; + start = newline + 1; + } + } + const parsed = parseSegments(); + if (parsed) yield parsed; + } finally { + stream.destroy(); + } +} + +/** + * Abort-signal check. + * + * @param signal Optional abort signal. + * @param filePath Current file path. + */ +function throwIfAborted(signal: AbortSignal | undefined, filePath: string): void { + if (signal?.aborted) { + throw new DOMException(`JSONL read aborted: ${filePath}`, "AbortError"); + } +} + +/** + * JSON object type guard. + * + * @param value Unknown value. + * @returns Whether it is a non-array object. + */ +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/AgentSourceCore/src/secret-redactor.ts b/AgentSourceCore/src/secret-redactor.ts new file mode 100644 index 000000000..49a9a4ef5 --- /dev/null +++ b/AgentSourceCore/src/secret-redactor.ts @@ -0,0 +1,120 @@ +/** Type definition for redaction rule. */ +type RedactionRule = { + /** Pattern. */ + pattern: RegExp; + /** Token. */ + token: string; + /** Replace. */ + replace?: (match: string, ...groups: string[]) => string; +}; + +const REDACTION_RULES: readonly RedactionRule[] = [ + { + pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + token: "[REDACTED:ssh_private_key]" + }, + { + pattern: /\b(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, + token: "[REDACTED:authorization_bearer]", + replace: (_match, prefix: string) => `${prefix}[REDACTED:authorization_bearer]` + }, + { + pattern: /\bsk-ant-api\d{2}-[A-Za-z0-9_-]{40,}\b/g, + token: "[REDACTED:anthropic_api_key]" + }, + { + pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}\b/g, + token: "[REDACTED:openai_api_key]" + }, + { + pattern: /\bAIza[A-Za-z0-9_-]{32,}\b/g, + token: "[REDACTED:google_api_key]" + }, + { + pattern: /\b([A-Za-z0-9_]*password[A-Za-z0-9_]*\s*[:=]\s*)(?:"[^"\n]+"|'[^'\n]+'|[^\s#&]+)/gi, + token: "[REDACTED:password]", + replace: (_match, prefix: string) => `${prefix}[REDACTED:password]` + } +]; + +const BASE64_SECRET_TOKEN = "[REDACTED:base64_secret]"; +const BASE64_SECRET_MIN_LENGTH = 32; +const LARGE_BASE64_PAYLOAD_MIN_LENGTH = 4096; + +/** + * Redacts common secrets from text. + * + * @param input Raw message text from an external Agent. + * @returns The plain text with secrets replaced, or the original text when no rule matches. + */ +export function redactSecrets(input: string): string { + const withoutLargeBinaryPayloads = redactBase64Runs(input, LARGE_BASE64_PAYLOAD_MIN_LENGTH); + const redacted = REDACTION_RULES.reduce((current, rule) => { + if (rule.replace) { + return current.replace(rule.pattern, rule.replace); + } + + return current.replace(rule.pattern, rule.token); + }, withoutLargeBinaryPayloads); + + return redactBase64Runs(redacted, BASE64_SECRET_MIN_LENGTH); +} + +function redactBase64Runs(input: string, minLength: number): string { + let output = ""; + let cursor = 0; + let index = 0; + + while (index < input.length) { + if (!isBase64CoreChar(input.charCodeAt(index))) { + index += 1; + continue; + } + + const start = index; + while (index < input.length && isBase64CoreChar(input.charCodeAt(index))) { + index += 1; + } + const coreEnd = index; + let padding = 0; + while (padding < 2 && input.charCodeAt(index) === 61) { + index += 1; + padding += 1; + } + + if (coreEnd - start >= minLength && hasBase64Boundary(input, start, index)) { + output += input.slice(cursor, start); + output += BASE64_SECRET_TOKEN; + cursor = index; + } + } + + if (cursor === 0) { + return input; + } + + return output + input.slice(cursor); +} + +function hasBase64Boundary(input: string, start: number, end: number): boolean { + return !isAsciiWord(input.charCodeAt(start - 1)) && !isAsciiWord(input.charCodeAt(end)); +} + +function isBase64CoreChar(code: number): boolean { + return ( + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 43 || + code === 47 + ); +} + +function isAsciiWord(code: number): boolean { + return ( + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 95 + ); +} diff --git a/App/backend/README.md b/App/backend/README.md index f4da7b39c..0de90c276 100644 --- a/App/backend/README.md +++ b/App/backend/README.md @@ -115,7 +115,7 @@ Every route in this table requires the local runtime token. | Claude Code | `~/.claude/projects/**/*.jsonl` | `~/.claude/CLAUDE.md`, `skills/memmy-memory/`, hooks, and the resume command | | Codex | `~/.codex/sessions/**/rollout-*.jsonl` | `~/.codex/AGENTS.md`, `skills/memmy-memory/`, and hooks | | OpenCode | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db` | `${XDG_CONFIG_HOME:-~/.config}/opencode/AGENTS.md`, `skills/memmy-memory/`, plugin, and resume command | -| OpenClaw | SQLite databases under `~/.openclaw/` | Workspace `AGENTS.md`, `~/.openclaw/skills/memmy-memory/`, and the Memory extension | +| OpenClaw | SQLite databases under `~/.openclaw/` | Workspace `../../AGENTS.md`, `~/.openclaw/skills/memmy-memory/`, and the Memory extension | | Hermes | `~/.hermes/sessions/**/*.jsonl` and `~/.hermes/state.db` | `~/.hermes/SOUL.md`, `skills/memmy-memory/`, and Memory/resume plugins | | WorkBuddy | `~/.workbuddy/projects/**/*.jsonl` | `~/.workbuddy/skills/memmy-memory/` | | Pi | `~/.pi/agent/sessions/**/*.jsonl` | `~/.pi/agent/skills/memmy-memory/` | diff --git a/App/backend/local-api-contracts/src/computer-use-contracts.test.ts b/App/backend/local-api-contracts/src/computer-use-contracts.test.ts new file mode 100644 index 000000000..cbce01820 --- /dev/null +++ b/App/backend/local-api-contracts/src/computer-use-contracts.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + COMPUTER_USE_ONBOARDING_PREFIX, + SCREEN_CAPTURE_PREFIX, + isComputerUseOnboardingRequest, + isComputerUsePermissions, + isComputerUseProbeTarget, + isScreenCaptureMessage, + isScreenCaptureRequest, + isScreenCaptureResult, +} from "./index.js"; + +describe("computer-use local API exports", () => { + it("exports and validates the permission onboarding messages", () => { + expect(COMPUTER_USE_ONBOARDING_PREFIX).toBe("memmy:computer-use-onboarding:"); + expect(isComputerUseOnboardingRequest({ + type: `${COMPUTER_USE_ONBOARDING_PREFIX}prepare`, + requestId: "prepare-1", + })).toBe(true); + expect(isComputerUseOnboardingRequest({ + type: `${COMPUTER_USE_ONBOARDING_PREFIX}guide`, + requestId: "guide-1", + reason: "accessibility", + helperApp: "/Applications/Open Computer Use.app", + canContinue: false, + })).toBe(true); + expect(isComputerUseOnboardingRequest({ + type: `${COMPUTER_USE_ONBOARDING_PREFIX}guide`, + requestId: "guide-1", + reason: "accessibility", + helperApp: "/tmp/helper.app", + })).toBe(false); + expect(isComputerUseOnboardingRequest({ + type: `${COMPUTER_USE_ONBOARDING_PREFIX}prepare`, + requestId: "prepare-1", + extra: true, + })).toBe(false); + }); + + it("bounds permission probe payloads and states", () => { + expect(isComputerUsePermissions({ + accessibility: "granted", + screenRecording: "required", + failure: "helperPauseFailed", + })).toBe(true); + expect(isComputerUsePermissions({ + accessibility: "granted", + screenRecording: "required", + unexpected: true, + })).toBe(false); + expect(isComputerUseProbeTarget({ app: "com.example.Helper", pid: 42 })).toBe(true); + expect(isComputerUseProbeTarget({ app: "/Applications/Helper.app", pid: 42 })).toBe(false); + expect(isComputerUseProbeTarget({ app: "com.example.Helper", pid: 0 })).toBe(false); + }); + + it("exports and validates screen capture requests and results", () => { + expect(SCREEN_CAPTURE_PREFIX).toBe("memmy:screen-capture:"); + const request = { + type: `${SCREEN_CAPTURE_PREFIX}request`, + requestId: "capture-1", + displayId: "7", + }; + expect(isScreenCaptureMessage(request)).toBe(true); + expect(isScreenCaptureRequest(request)).toBe(true); + expect(isScreenCaptureRequest({ ...request, displayId: "../../secret" })).toBe(false); + expect(isScreenCaptureRequest({ ...request, command: "open" })).toBe(false); + + expect(isScreenCaptureResult({ + ok: true, + pngBase64: "png", + displayId: "7", + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + width: 1280, + height: 720, + })).toBe(true); + expect(isScreenCaptureResult({ + ok: false, + code: "permission_required", + message: "Screen recording permission is required", + })).toBe(true); + expect(isScreenCaptureResult({ + ok: false, + code: "permission_required", + message: "x".repeat(2001), + })).toBe(false); + }); +}); diff --git a/App/backend/local-api-contracts/src/computer-use-onboarding.ts b/App/backend/local-api-contracts/src/computer-use-onboarding.ts new file mode 100644 index 000000000..2c07dd498 --- /dev/null +++ b/App/backend/local-api-contracts/src/computer-use-onboarding.ts @@ -0,0 +1,41 @@ +export const COMPUTER_USE_ONBOARDING_PREFIX = 'memmy:computer-use-onboarding:'; +export type ComputerUseGuideReason = 'accessibility' | 'screenCaptureUnavailable'; +export type ComputerUseProbeTarget = { app: string; pid: number }; +export type ComputerUsePermissionState = 'granted' | 'required' | 'unknown'; +export type ComputerUsePermissions = { + accessibility: ComputerUsePermissionState; + screenRecording: ComputerUsePermissionState; + failure?: 'unavailable' | 'helperPauseFailed'; +}; +export function isComputerUsePermissions(value: any): value is ComputerUsePermissions { + return !!value && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value).every(key => ['accessibility', 'screenRecording', 'failure'].includes(key)) + && ['granted', 'required', 'unknown'].includes(value.accessibility) + && ['granted', 'required', 'unknown'].includes(value.screenRecording) + && (value.failure === undefined || ['unavailable', 'helperPauseFailed'].includes(value.failure)); +} +export type ComputerUseOnboardingRequest = { + type: 'memmy:computer-use-onboarding:prepare' | 'memmy:computer-use-onboarding:guide'; + requestId: string; + reason?: ComputerUseGuideReason; + helperApp?: string; + canContinue?: boolean; +}; +export function isComputerUseGuideReason(value: unknown): value is ComputerUseGuideReason { + return value === 'accessibility' || value === 'screenCaptureUnavailable'; +} +export function isComputerUseOnboardingRequest(value: any): value is ComputerUseOnboardingRequest { + return !!value && typeof value === 'object' && !Array.isArray(value) + && typeof value.requestId === 'string' && /^[a-zA-Z0-9-]{1,80}$/.test(value.requestId) + && Object.keys(value).every(key => ['type', 'requestId', 'reason', 'helperApp', 'canContinue'].includes(key)) + && ((value.type === `${COMPUTER_USE_ONBOARDING_PREFIX}prepare` && value.reason === undefined && value.helperApp === undefined && value.canContinue === undefined) + || (value.type === `${COMPUTER_USE_ONBOARDING_PREFIX}guide` && isComputerUseGuideReason(value.reason) + && (value.canContinue === undefined || typeof value.canContinue === 'boolean') + && typeof value.helperApp === 'string' && value.helperApp.startsWith('/') && value.helperApp.endsWith('/Open Computer Use.app') + && value.helperApp.length <= 4096 && !/[\r\n\0]/.test(value.helperApp))); +} +export function isComputerUseProbeTarget(value: any): value is ComputerUseProbeTarget { + return !!value && typeof value === 'object' && typeof value.app === 'string' + && /^[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+$/.test(value.app) && value.app.length <= 200 + && Number.isSafeInteger(value.pid) && value.pid > 0; +} diff --git a/App/backend/local-api-contracts/src/desktop-runtime-manifest.ts b/App/backend/local-api-contracts/src/desktop-runtime-manifest.ts index a65c5baa1..32966868b 100644 --- a/App/backend/local-api-contracts/src/desktop-runtime-manifest.ts +++ b/App/backend/local-api-contracts/src/desktop-runtime-manifest.ts @@ -11,18 +11,18 @@ export interface DesktopRuntimeManifest { */ export function normalizePublicCloudService(value: unknown): string { if (typeof value !== "string" || !value.trim()) { - throw new Error("MEMMY_CLOUD_SERVICE must be a non-empty HTTPS origin"); + throw new Error("MEMMY_CLOUD_SERVICE must be a non-empty HTTP(S) origin"); } let url: URL; try { url = new URL(value.trim()); } catch { - throw new Error("MEMMY_CLOUD_SERVICE must be a valid HTTPS origin"); + throw new Error("MEMMY_CLOUD_SERVICE must be a valid HTTP(S) origin"); } - if (url.protocol !== "https:") { - throw new Error("MEMMY_CLOUD_SERVICE must use HTTPS"); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("MEMMY_CLOUD_SERVICE must use HTTP or HTTPS"); } if (url.username || url.password) { throw new Error("MEMMY_CLOUD_SERVICE must not contain credentials"); diff --git a/App/backend/local-api-contracts/src/desktop-screen-capture.ts b/App/backend/local-api-contracts/src/desktop-screen-capture.ts new file mode 100644 index 000000000..8c546fc98 --- /dev/null +++ b/App/backend/local-api-contracts/src/desktop-screen-capture.ts @@ -0,0 +1,31 @@ +export const SCREEN_CAPTURE_PROTOCOL = 1; +export const SCREEN_CAPTURE_MAX_BYTES = 900 * 1024; +export const SCREEN_CAPTURE_MAX_EDGE = 1280; +export const SCREEN_CAPTURE_PREFIX = 'memmy:screen-capture:'; +export type ScreenCaptureResult = { + ok: true; pngBase64: string; displayId: string; + bounds: { x: number; y: number; width: number; height: number }; + width: number; height: number; +} | { ok: false; code: 'permission_required' | 'unavailable' | 'capture_failed' | 'cancelled'; message: string }; +export type ScreenCaptureRequest = { type: 'memmy:screen-capture:request'; requestId: string; displayId?: string }; +export function isScreenCaptureMessage(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) + && typeof (value as any).type === 'string' && (value as any).type.startsWith(SCREEN_CAPTURE_PREFIX) + && typeof (value as any).requestId === 'string' && /^[A-Za-z0-9-]{1,80}$/.test((value as any).requestId); +} +export function isScreenCaptureRequest(value: unknown): value is ScreenCaptureRequest { + return isScreenCaptureMessage(value) && value.type === `${SCREEN_CAPTURE_PREFIX}request` + && (value.displayId === undefined || (typeof value.displayId === 'string' && /^\d{1,16}$/.test(value.displayId))) + && Object.keys(value).every(key => ['type', 'requestId', 'displayId'].includes(key)); +} +export function isScreenCaptureResult(value: any): value is ScreenCaptureResult { + if (!value || typeof value !== 'object') return false; + if (value.ok === false) return ['permission_required', 'unavailable', 'capture_failed', 'cancelled'].includes(value.code) + && typeof value.message === 'string' && value.message.length <= 2000; + return value.ok === true && typeof value.pngBase64 === 'string' + && value.pngBase64.length <= Math.ceil(SCREEN_CAPTURE_MAX_BYTES / 3) * 4 + && typeof value.displayId === 'string' && /^\d{1,16}$/.test(value.displayId) + && [value.width, value.height].every(n => Number.isInteger(n) && n > 0 && n <= SCREEN_CAPTURE_MAX_EDGE) + && !!value.bounds && ['x', 'y', 'width', 'height'].every(key => Number.isFinite(value.bounds[key])) + && value.bounds.width > 0 && value.bounds.height > 0; +} diff --git a/App/backend/local-api-contracts/src/index.ts b/App/backend/local-api-contracts/src/index.ts index 23ff36e2c..30c117688 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -663,7 +663,9 @@ export const ModelProviderSchema = z.enum([ "kimi", "minimax", "baidu", - "doubao" + "doubao", + "stepfun", + "xiaomi" ]); export type ModelProvider = z.infer; @@ -678,6 +680,8 @@ export const CatalogProviderIdSchema = z.enum([ "minimax", "qianfan", "volcengine", + "stepfun", + "xiaomi_mimo", "memmy_account" ]); export type CatalogProviderId = z.infer; @@ -688,7 +692,8 @@ const CATALOG_PROVIDER_ALIASES: Readonly> = { qwen: "dashscope", kimi: "moonshot", baidu: "qianfan", - doubao: "volcengine" + doubao: "volcengine", + xiaomi: "xiaomi_mimo" }; export function canonicalCatalogProviderId(value: string): CatalogProviderId | null { @@ -1096,6 +1101,10 @@ export type AccountChannel = z.infer; export const AccountLocaleSchema = z.enum(["zh", "en"]); export type AccountLocale = z.infer; +/** Third-party sign-in providers supported by the international desktop package. */ +export const SocialLoginProviderSchema = z.enum(["google", "github"]); +export type SocialLoginProvider = z.infer; + /** Definition for send code input. */ export const SendCodeInputSchema = z .object({ @@ -1191,6 +1200,48 @@ export const AccountLoginResultViewSchema = z.object({ }); export type AccountLoginResultView = z.infer; +/** Starts a browser-based social-login flow. */ +export const StartSocialLoginInputSchema = z.object({ + provider: SocialLoginProviderSchema, + locale: AccountLocaleSchema, + loginSource: z.literal("Memmy"), + invitationCode: z.string().trim().max(12).optional() +}); +export type StartSocialLoginInput = z.infer; + +/** Opaque credentials used by the desktop client to poll a social-login flow. */ +export const StartSocialLoginResponseSchema = z.object({ + flowId: z.string().min(16), + pollToken: z.string().min(32), + authorizationUrl: z.string().url(), + expiresInSec: z.number().int().positive().max(1800), + pollIntervalSec: z.number().int().positive().max(30) +}); +export type StartSocialLoginResponse = z.infer; + +/** Identifies a previously started social-login flow. */ +export const SocialLoginStatusInputSchema = z.object({ + flowId: z.string().min(16), + pollToken: z.string().min(32) +}); +export type SocialLoginStatusInput = z.infer; + +/** Current state of a browser-based social-login flow. */ +export const SocialLoginStatusResponseSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("pending") }), + z.object({ + status: z.literal("completed"), + result: AccountLoginResultViewSchema + }), + z.object({ + status: z.literal("failed"), + code: z.string().min(1).optional(), + message: z.string().min(1) + }), + z.object({ status: z.literal("expired") }) +]); +export type SocialLoginStatusResponse = z.infer; + /** Current account invitation code and today's reserved-slot summary. */ export const AccountInvitationViewSchema = z.object({ enabled: z.boolean(), @@ -1555,3 +1606,5 @@ export const TokenQuotaEligibilitySchema = z.object({ latestReviewNote: z.string().nullable() }); export type TokenQuotaEligibility = z.infer; +export * from './desktop-screen-capture.js'; +export * from './computer-use-onboarding.js'; diff --git a/App/backend/local-api-contracts/src/memory-runtime.ts b/App/backend/local-api-contracts/src/memory-runtime.ts index 003b33675..28b3adae3 100644 --- a/App/backend/local-api-contracts/src/memory-runtime.ts +++ b/App/backend/local-api-contracts/src/memory-runtime.ts @@ -22,7 +22,7 @@ export const CursorSchema = z.string(); export type Cursor = z.infer; /** Schema for memory kind. */ -export const MemoryKindSchema = z.enum(["user_memory", "trace", "span", "policy", "world_model", "skill"]); +export const MemoryKindSchema = z.enum(["user_memory", "trace", "span", "policy", "world_model", "skill", "work_memory"]); export type MemoryKind = z.infer; /** Schema for memory layer. */ @@ -55,7 +55,9 @@ export const JobTypeSchema = z.enum([ "l3_world_model_update", "project_environment_profile", "skill_crystallization", - "skill_trial_resolve" + "skill_trial_resolve", + "decision_repair", + "work_memory_extract" ]); export type JobType = z.infer; @@ -216,7 +218,13 @@ export const MemoryDetailItemSchema = MemoryListItemSchema.extend({ body: z.string(), createdAt: IsoTimeSchema, sourceMemoryIds: z.array(NonEmptyStringSchema), - metadata: UnknownRecordSchema + metadata: UnknownRecordSchema, + workMemory: z.object({ + workTopic: z.string().optional(), + requirement: z.string().optional(), + reason: z.string().optional(), + projectId: z.string().nullable().optional() + }).optional() }); export type MemoryDetailItem = z.infer; @@ -467,6 +475,31 @@ export const CompleteTurnOutputSchema = z.object({ }); export type CompleteTurnOutput = z.infer; +/** Completed native Agent turn shared by Hook and automatic scanning. */ +export const SourceTurnCompleteInputSchema = CompleteTurnInputSchema.omit({ sessionId: true }).extend({ + sessionId: NonEmptyStringSchema.optional(), + sourceTurn: z.object({ + source: NonEmptyStringSchema, + profileId: NonEmptyStringSchema, + conversationId: NonEmptyStringSchema, + turnId: NonEmptyStringSchema, + startedAt: IsoTimeSchema, + completedAt: IsoTimeSchema, + sequence: z.number().int().nonnegative().optional(), + completionEvidence: NonEmptyStringSchema + }), + channel: z.enum(["hook", "agent_source_scan"]), + workspacePath: z.string().optional() +}); +export type SourceTurnCompleteInput = z.infer; + +export const SourceTurnCompleteOutputSchema = z.object({ + status: z.enum(["stored", "existing", "rejected", "pending", "conflict"]), + reason: z.string().optional(), + result: CompleteTurnOutputSchema.partial({ changeSeq: true }).optional() +}); +export type SourceTurnCompleteOutput = z.infer; + /** Definition for search input. */ export const SearchInputSchema = RuntimeRequestFieldsSchema.extend({ query: NonEmptyStringSchema, diff --git a/App/backend/local-api-contracts/tests/desktop-runtime-manifest.test.ts b/App/backend/local-api-contracts/tests/desktop-runtime-manifest.test.ts index a3816badb..dfb2576a5 100644 --- a/App/backend/local-api-contracts/tests/desktop-runtime-manifest.test.ts +++ b/App/backend/local-api-contracts/tests/desktop-runtime-manifest.test.ts @@ -14,11 +14,14 @@ describe("desktop runtime manifest", () => { JSON.stringify({ edition: "cn", cloudService: "https://api.example.test" }), ), ).toBe("https://api.example.test"); + expect(normalizePublicCloudService(" http://192.0.2.10:8101/ ")).toBe( + "http://192.0.2.10:8101", + ); }); it.each([ "", - "http://api.example.test", + "ftp://api.example.test", "https://user:password@api.example.test", "https://api.example.test/path", "https://api.example.test?token=secret", diff --git a/App/backend/local-api-contracts/tests/desktop-screen-capture.test.ts b/App/backend/local-api-contracts/tests/desktop-screen-capture.test.ts new file mode 100644 index 000000000..25f8236d1 --- /dev/null +++ b/App/backend/local-api-contracts/tests/desktop-screen-capture.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from 'vitest'; +import { isScreenCaptureRequest, isScreenCaptureResult } from '../src/desktop-screen-capture.js'; +it('accepts only bounded display requests, never paths or execution payloads', () => { + const request = { type: 'memmy:screen-capture:request', requestId: 'test-1', displayId: '7' }; + expect(isScreenCaptureRequest(request)).toBe(true); + expect(isScreenCaptureRequest({ ...request, displayId: '../../file' })).toBe(false); + expect(isScreenCaptureRequest({ ...request, command: 'open' })).toBe(false); + expect(isScreenCaptureRequest({ ...request, requestId: 'x'.repeat(100) })).toBe(false); +}); +it('rejects malformed or oversized responses', () => { + const result = { ok: true, pngBase64: 'png', displayId: '7', bounds: { x: 0, y: 0, width: 1920, height: 1080 }, width: 1280, height: 720 }; + expect(isScreenCaptureResult(result)).toBe(true); + expect(isScreenCaptureResult({ ...result, pngBase64: 'x'.repeat(2 * 1024 * 1024) })).toBe(false); + expect(isScreenCaptureResult({ ...result, width: Infinity })).toBe(false); + expect(isScreenCaptureResult({ ...result, bounds: null })).toBeFalsy(); +}); diff --git a/App/backend/src/adapters/inbound/local-api/routes/account.ts b/App/backend/src/adapters/inbound/local-api/routes/account.ts index e61d77166..2a2816a49 100644 --- a/App/backend/src/adapters/inbound/local-api/routes/account.ts +++ b/App/backend/src/adapters/inbound/local-api/routes/account.ts @@ -9,6 +9,10 @@ import { SendCodeInputSchema, SendCodeResponseSchema, SetAvatarInputSchema, + SocialLoginStatusInputSchema, + SocialLoginStatusResponseSchema, + StartSocialLoginInputSchema, + StartSocialLoginResponseSchema, UpdateAccountProfileInputSchema, VerifyCodeInputSchema } from "@memmy/local-api-contracts"; @@ -46,6 +50,30 @@ export function registerAccountRoutes(app: FastifyInstance, options: RegisterAcc }) ); + app.post( + "/api/account/oauth/start", + { preHandler: options.authenticateRuntimeToken }, + withErrorEnvelope(async (request, reply) => { + const input = StartSocialLoginInputSchema.parse(request.body); + const response = StartSocialLoginResponseSchema.parse( + await options.account.startSocialLogin(input) + ); + return reply.send(response); + }) + ); + + app.post( + "/api/account/oauth/status", + { preHandler: options.authenticateRuntimeToken }, + withErrorEnvelope(async (request, reply) => { + const input = SocialLoginStatusInputSchema.parse(request.body); + const response = SocialLoginStatusResponseSchema.parse( + await options.account.getSocialLoginStatus(input) + ); + return reply.send(response); + }) + ); + app.put( "/api/account/invitation", { preHandler: options.authenticateRuntimeToken }, diff --git a/App/backend/src/adapters/inbound/local-api/tests/account-routes.test.ts b/App/backend/src/adapters/inbound/local-api/tests/account-routes.test.ts index 17ad26f4b..64b74c9da 100644 --- a/App/backend/src/adapters/inbound/local-api/tests/account-routes.test.ts +++ b/App/backend/src/adapters/inbound/local-api/tests/account-routes.test.ts @@ -168,6 +168,26 @@ describe("account local api routes", () => { } }); }); + + it("forwards social-login start and status through the runtime-token boundary", async () => { + app = createServer(); + + const start = await injectJson("POST", "/api/account/oauth/start", { + provider: "google", + locale: "en", + loginSource: "Memmy" + }); + const status = await injectJson("POST", "/api/account/oauth/status", { + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001" + }); + + expect(start.json()).toMatchObject({ + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + expiresInSec: 600 + }); + expect(status.json()).toEqual({ status: "pending" }); + }); }); async function injectJson(method: string, url: string, payload: unknown) { @@ -211,6 +231,18 @@ function createServer(overrides: Record = {}): FastifyInstance invitationResult: { status: "not_provided" } }; }, + async startSocialLogin() { + return { + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + expiresInSec: 600, + pollIntervalSec: 2 + }; + }, + async getSocialLoginStatus() { + return { status: "pending" as const }; + }, async getInvitation() { return { enabled: false, diff --git a/App/backend/src/adapters/outbound/agent-source/codex/adapter.ts b/App/backend/src/adapters/outbound/agent-source/codex/adapter.ts index 62b5dcab9..fd11dfd00 100644 --- a/App/backend/src/adapters/outbound/agent-source/codex/adapter.ts +++ b/App/backend/src/adapters/outbound/agent-source/codex/adapter.ts @@ -102,7 +102,8 @@ function toConversationMessage( createdAt: rawMessage.createdAt, workspacePath, gitRoot, - rawMeta: Object.freeze({}) + ordinal: rawMessage.ordinal, + rawMeta: rawMessage.rawMeta }; } diff --git a/App/backend/src/adapters/outbound/agent-source/codex/rollout-reader.ts b/App/backend/src/adapters/outbound/agent-source/codex/rollout-reader.ts index 6dd193392..9b2de0524 100644 --- a/App/backend/src/adapters/outbound/agent-source/codex/rollout-reader.ts +++ b/App/backend/src/adapters/outbound/agent-source/codex/rollout-reader.ts @@ -1,208 +1 @@ -/** Rollout reader module. */ -import { basename } from "node:path"; -import { readJsonlObjects, type JsonObject } from "../jsonl-lines.js"; - -const MAX_TOOL_NAME_ENTRIES = 4096; - -export interface RawCodexMessage { - /** Message id. */ - messageId: string; - conversationId: string; - role: "user" | "assistant" | "tool" | "system"; - content: string; - createdAt: string; -} - -/** Rollout reader module. */ -export async function* readCodexRollout(filePath: string, signal?: AbortSignal): AsyncIterable { - const rolloutId = parseRolloutId(filePath); - const toolNamesByCallId = new Map(); - let lineNumber = 0; - - for await (const record of readJsonlObjects(filePath, signal)) { - lineNumber += 1; - const message = toRawCodexMessage(record, rolloutId, lineNumber, toolNamesByCallId); - if (message) { - yield message; - } - } -} - -/** Handles to raw codex message. */ -function toRawCodexMessage( - record: JsonObject, - rolloutId: string, - lineNumber: number, - toolNamesByCallId: Map -): RawCodexMessage | null { - if (record.type !== "response_item" || !isRecord(record.payload)) { - return null; - } - - if (record.payload.type !== "message") { - return toToolMessage(record.payload, rolloutId, lineNumber, normalizeTimestamp(record.timestamp), toolNamesByCallId); - } - - const role = record.payload.role; - if (role !== "user" && role !== "assistant" && role !== "developer" && role !== "system") { - return null; - } - - const content = getContentText(record.payload.content); - if (!content) { - return null; - } - - return { - messageId: `${rolloutId}:${lineNumber}`, - conversationId: rolloutId, - role: role === "developer" ? "system" : role, - content, - createdAt: normalizeTimestamp(record.timestamp) - }; -} - -function toToolMessage( - payload: Record, - rolloutId: string, - lineNumber: number, - createdAt: string, - toolNamesByCallId: Map -): RawCodexMessage | null { - const type = payload.type; - if (type === "function_call" || type === "custom_tool_call") { - const callId = getString(payload.call_id) ?? getString(payload.id); - const name = getString(payload.name) ?? "tool"; - if (callId) { - toolNamesByCallId.set(callId, name); - if (toolNamesByCallId.size > MAX_TOOL_NAME_ENTRIES) { - const oldest = toolNamesByCallId.keys().next().value; - if (typeof oldest === "string") toolNamesByCallId.delete(oldest); - } - } - return { - messageId: `${rolloutId}:${lineNumber}`, - conversationId: rolloutId, - role: "tool", - content: renderToolMessage({ - name, - callId, - status: getString(payload.status), - input: firstDefined(payload.arguments, payload.input) - }), - createdAt - }; - } - - if (type === "function_call_output" || type === "custom_tool_call_output") { - const callId = getString(payload.call_id) ?? getString(payload.id); - return { - messageId: `${rolloutId}:${lineNumber}`, - conversationId: rolloutId, - role: "tool", - content: renderToolMessage({ - name: callId ? toolNamesByCallId.get(callId) ?? "tool" : "tool", - callId, - status: getString(payload.status), - output: firstDefined(payload.output, payload.result) - }), - createdAt - }; - } - - if (type === "web_search_call") { - return { - messageId: `${rolloutId}:${lineNumber}`, - conversationId: rolloutId, - role: "tool", - content: renderToolMessage({ - name: "web_search", - callId: getString(payload.call_id) ?? getString(payload.id), - status: getString(payload.status), - input: payload.action - }), - createdAt - }; - } - - return null; -} - -/** - * Parses the rollout uuid from the file name. - * - * @param filePath Rollout path. - * @returns The uuid, falling back to the file name. - */ -function parseRolloutId(filePath: string): string { - const name = basename(filePath).replace(/\.jsonl$/, ""); - const uuid = name.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)?.[0]; - return uuid ?? name; -} - -/** - * Extracts the text from Codex content. - * - * @param content Raw payload.content value. - * @returns The merged text, or null. - */ -function getContentText(content: unknown): string | null { - if (!Array.isArray(content)) { - return null; - } - - const text = content - .filter(isRecord) - .map((item) => (typeof item.text === "string" ? item.text : null)) - .filter((item): item is string => Boolean(item)) - .join("\n"); - return text.length > 0 ? text : null; -} - -function renderToolMessage(input: { - name: string; - callId?: string; - status?: string; - input?: unknown; - output?: unknown; -}): string { - return [ - `Tool: ${input.name}`, - input.callId ? `Call ID: ${input.callId}` : undefined, - input.status ? `Status: ${input.status}` : undefined, - input.input !== undefined ? `Input:\n${formatToolPayload(input.input)}` : undefined, - input.output !== undefined ? `Output:\n${formatToolPayload(input.output)}` : undefined - ].filter(Boolean).join("\n\n"); -} - -function formatToolPayload(value: unknown): string { - if (typeof value === "string") { - return value.trim(); - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -function firstDefined(...values: unknown[]): unknown { - return values.find((value) => value !== undefined && value !== null); -} - -function getString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function normalizeTimestamp(value: unknown): string { - if (typeof value === "string") { - const date = new Date(value); - return Number.isNaN(date.getTime()) ? new Date(0).toISOString() : date.toISOString(); - } - - return new Date(0).toISOString(); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +export { readCodexRollout, type RawCodexMessage } from "@memmy/agent-source-core"; diff --git a/App/backend/src/adapters/outbound/agent-source/codex/tests/adapter.test.ts b/App/backend/src/adapters/outbound/agent-source/codex/tests/adapter.test.ts index bf6d549f7..fe299fafb 100644 --- a/App/backend/src/adapters/outbound/agent-source/codex/tests/adapter.test.ts +++ b/App/backend/src/adapters/outbound/agent-source/codex/tests/adapter.test.ts @@ -27,23 +27,23 @@ describe("codex source adapter", () => { expect(messages).toEqual([ expect.objectContaining({ - messageId: "019e72be-500b-7f02-9400-112c5a194e5c:2", + messageId: "019e72be-500b-7f02-9400-112c5a194e5c:000000000002", conversationId: "019e72be-500b-7f02-9400-112c5a194e5c", role: "user", content: `Use OPENAI_API_KEY=${SYNTHETIC_API_KEY}` }), expect.objectContaining({ - messageId: "019e72be-500b-7f02-9400-112c5a194e5c:3", + messageId: "019e72be-500b-7f02-9400-112c5a194e5c:000000000003", role: "tool", content: expect.stringContaining("Tool: shell") }), expect.objectContaining({ - messageId: "019e72be-500b-7f02-9400-112c5a194e5c:4", + messageId: "019e72be-500b-7f02-9400-112c5a194e5c:000000000004", role: "tool", content: expect.stringContaining("Output:") }), expect.objectContaining({ - messageId: "019e72be-500b-7f02-9400-112c5a194e5c:5", + messageId: "019e72be-500b-7f02-9400-112c5a194e5c:000000000005", role: "assistant", content: "Done" }) diff --git a/App/backend/src/adapters/outbound/agent-source/jsonl-lines.ts b/App/backend/src/adapters/outbound/agent-source/jsonl-lines.ts index c0fd7cbd6..a01d98e59 100644 --- a/App/backend/src/adapters/outbound/agent-source/jsonl-lines.ts +++ b/App/backend/src/adapters/outbound/agent-source/jsonl-lines.ts @@ -1,98 +1 @@ -/** Jsonl lines module. */ -import { createReadStream } from "node:fs"; - -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; -export type JsonObject = { readonly [key: string]: JsonValue }; - -/** - * Streams valid object rows from a JSONL file. - * Malformed and non-object rows are skipped without interrupting the stream. - * - * @param filePath JSONL file path. - * @param signal Optional abort signal. - * @returns The JSON objects parsed line by line. - */ -export async function* readJsonlObjects(filePath: string, signal?: AbortSignal): AsyncIterable { - const stream = createReadStream(filePath); - const maxRecordBytes = 64 * 1024 * 1024; - let segments: Buffer[] = []; - let recordBytes = 0; - let overLimit = false; - - const append = (segment: Buffer): void => { - if (overLimit || segment.length === 0) return; - recordBytes += segment.length; - if (recordBytes > maxRecordBytes) { - segments = []; - overLimit = true; - return; - } - segments.push(segment); - }; - - const reset = (): void => { - segments = []; - recordBytes = 0; - overLimit = false; - }; - - const parseSegments = (): JsonObject | null => { - if (overLimit) return null; - const line = segments.length === 1 ? segments[0]! : Buffer.concat(segments, recordBytes); - const text = line.toString("utf8").trim(); - if (!text) return null; - try { - const parsed = JSON.parse(text) as unknown; - return isJsonObject(parsed) ? parsed : null; - } catch { - return null; - } - }; - - try { - for await (const chunk of stream) { - throwIfAborted(signal, filePath); - const buffer = chunk as Buffer; - let start = 0; - while (start <= buffer.length) { - const newline = buffer.indexOf(0x0a, start); - if (newline < 0) { - append(buffer.subarray(start)); - break; - } - append(buffer.subarray(start, newline)); - const parsed = parseSegments(); - reset(); - if (parsed) yield parsed; - start = newline + 1; - } - } - const parsed = parseSegments(); - if (parsed) yield parsed; - } finally { - stream.destroy(); - } -} - -/** - * Abort-signal check. - * - * @param signal Optional abort signal. - * @param filePath Current file path. - */ -function throwIfAborted(signal: AbortSignal | undefined, filePath: string): void { - if (signal?.aborted) { - throw new DOMException(`JSONL read aborted: ${filePath}`, "AbortError"); - } -} - -/** - * JSON object type guard. - * - * @param value Unknown value. - * @returns Whether it is a non-array object. - */ -function isJsonObject(value: unknown): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +export { readJsonlObjects, type JsonObject, type JsonValue, type JsonPrimitive } from "@memmy/agent-source-core"; diff --git a/App/backend/src/adapters/outbound/agent-source/secret-redactor.ts b/App/backend/src/adapters/outbound/agent-source/secret-redactor.ts index 49a9a4ef5..7038a55b1 100644 --- a/App/backend/src/adapters/outbound/agent-source/secret-redactor.ts +++ b/App/backend/src/adapters/outbound/agent-source/secret-redactor.ts @@ -1,120 +1 @@ -/** Type definition for redaction rule. */ -type RedactionRule = { - /** Pattern. */ - pattern: RegExp; - /** Token. */ - token: string; - /** Replace. */ - replace?: (match: string, ...groups: string[]) => string; -}; - -const REDACTION_RULES: readonly RedactionRule[] = [ - { - pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, - token: "[REDACTED:ssh_private_key]" - }, - { - pattern: /\b(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, - token: "[REDACTED:authorization_bearer]", - replace: (_match, prefix: string) => `${prefix}[REDACTED:authorization_bearer]` - }, - { - pattern: /\bsk-ant-api\d{2}-[A-Za-z0-9_-]{40,}\b/g, - token: "[REDACTED:anthropic_api_key]" - }, - { - pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}\b/g, - token: "[REDACTED:openai_api_key]" - }, - { - pattern: /\bAIza[A-Za-z0-9_-]{32,}\b/g, - token: "[REDACTED:google_api_key]" - }, - { - pattern: /\b([A-Za-z0-9_]*password[A-Za-z0-9_]*\s*[:=]\s*)(?:"[^"\n]+"|'[^'\n]+'|[^\s#&]+)/gi, - token: "[REDACTED:password]", - replace: (_match, prefix: string) => `${prefix}[REDACTED:password]` - } -]; - -const BASE64_SECRET_TOKEN = "[REDACTED:base64_secret]"; -const BASE64_SECRET_MIN_LENGTH = 32; -const LARGE_BASE64_PAYLOAD_MIN_LENGTH = 4096; - -/** - * Redacts common secrets from text. - * - * @param input Raw message text from an external Agent. - * @returns The plain text with secrets replaced, or the original text when no rule matches. - */ -export function redactSecrets(input: string): string { - const withoutLargeBinaryPayloads = redactBase64Runs(input, LARGE_BASE64_PAYLOAD_MIN_LENGTH); - const redacted = REDACTION_RULES.reduce((current, rule) => { - if (rule.replace) { - return current.replace(rule.pattern, rule.replace); - } - - return current.replace(rule.pattern, rule.token); - }, withoutLargeBinaryPayloads); - - return redactBase64Runs(redacted, BASE64_SECRET_MIN_LENGTH); -} - -function redactBase64Runs(input: string, minLength: number): string { - let output = ""; - let cursor = 0; - let index = 0; - - while (index < input.length) { - if (!isBase64CoreChar(input.charCodeAt(index))) { - index += 1; - continue; - } - - const start = index; - while (index < input.length && isBase64CoreChar(input.charCodeAt(index))) { - index += 1; - } - const coreEnd = index; - let padding = 0; - while (padding < 2 && input.charCodeAt(index) === 61) { - index += 1; - padding += 1; - } - - if (coreEnd - start >= minLength && hasBase64Boundary(input, start, index)) { - output += input.slice(cursor, start); - output += BASE64_SECRET_TOKEN; - cursor = index; - } - } - - if (cursor === 0) { - return input; - } - - return output + input.slice(cursor); -} - -function hasBase64Boundary(input: string, start: number, end: number): boolean { - return !isAsciiWord(input.charCodeAt(start - 1)) && !isAsciiWord(input.charCodeAt(end)); -} - -function isBase64CoreChar(code: number): boolean { - return ( - (code >= 65 && code <= 90) || - (code >= 97 && code <= 122) || - (code >= 48 && code <= 57) || - code === 43 || - code === 47 - ); -} - -function isAsciiWord(code: number): boolean { - return ( - (code >= 65 && code <= 90) || - (code >= 97 && code <= 122) || - (code >= 48 && code <= 57) || - code === 95 - ); -} +export { redactSecrets } from "@memmy/agent-source-core"; diff --git a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts index 873ee3b6b..b1cdb6b23 100644 --- a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts +++ b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts @@ -23,6 +23,7 @@ import { type IntegrationToolResult, type OkResponse, type PromotionFlags, + type SocialLoginProvider, type TokenSceneUsageDto } from "@memmy/local-api-contracts"; import type { @@ -38,6 +39,10 @@ import type { CloudIntegrationSessionInput, CloudLoginInput, CloudLoginResult, + CloudSocialLoginCredentials, + CloudSocialLoginStatus, + CloudStartSocialLoginInput, + CloudStartSocialLoginResult, CloudLogoutInput, GetAccountInfoInput, EnsureInvitationCodeInput, @@ -129,25 +134,44 @@ export function createHttpCloudClient(options: CreateHttpCloudClientOptions = {} ...(input.email ? { toError: toCloudEmailVerificationError } : {}) }); - const uuid = readString(data.uuid) ?? readString(data.token); - if (!uuid) { - throw new Error("Cloud login response missing uuid"); - } - const profile = toCloudAccountProfile(data); - const invitationResult = InvitationResultSchema.safeParse(data.invitationResult); - const userType = readString(data.userType); + return toCloudLoginResult(data); + }, - return { - uuid, - accountUuid: resolveAccountUuid(data, profile), - profile, - isNewUser: userType === "NEW_USER" - ? true - : readBoolean(data.isNewUser, data.newUser, data.is_new_user, data.new_user, data.firstLogin, data.isFirstLogin), - invitationResult: invitationResult.success - ? invitationResult.data - : { status: "not_provided" } - }; + async startSocialLogin(input: CloudStartSocialLoginInput): Promise { + const data = await requestCloudData>( + fetchImpl, + baseUrl, + timeoutMs, + "/api/agentUser/oauth/start", + { + body: { + provider: input.provider, + locale: input.locale, + loginSource: input.loginSource.toLowerCase(), + ...(input.invitationCode ? { invitationCode: input.invitationCode } : {}) + }, + lang: input.locale, + deviceId + } + ); + const result = toCloudStartSocialLoginResult(data); + assertProviderAuthorizationUrl(input.provider, result.authorizationUrl); + return result; + }, + + async getSocialLoginStatus(input: CloudSocialLoginCredentials): Promise { + const data = await requestCloudData>( + fetchImpl, + baseUrl, + timeoutMs, + "/api/agentUser/oauth/status", + { + body: { flowId: input.flowId, pollToken: input.pollToken }, + lang: "en", + deviceId + } + ); + return toCloudSocialLoginStatus(data); }, async ensureInvitationCode(input: EnsureInvitationCodeInput): Promise { @@ -529,6 +553,72 @@ function toCloudAccountProfile(data: Record): CloudAccountProfi }; } +function toCloudLoginResult(data: Record): CloudLoginResult { + const uuid = readString(data.uuid) ?? readString(data.token); + if (!uuid) { + throw new Error("Cloud login response missing uuid"); + } + const profile = toCloudAccountProfile(data); + const invitationResult = InvitationResultSchema.safeParse(data.invitationResult); + const userType = readString(data.userType); + + return { + uuid, + accountUuid: resolveAccountUuid(data, profile), + profile, + isNewUser: userType === "NEW_USER" + ? true + : readBoolean(data.isNewUser, data.newUser, data.is_new_user, data.new_user, data.firstLogin, data.isFirstLogin), + invitationResult: invitationResult.success + ? invitationResult.data + : { status: "not_provided" } + }; +} + +function toCloudStartSocialLoginResult(data: Record): CloudStartSocialLoginResult { + const flowId = readString(data.flowId); + const pollToken = readString(data.pollToken); + const authorizationUrl = readString(data.authorizationUrl); + const expiresInSec = readInteger(data.expiresInSec); + const pollIntervalSec = readInteger(data.pollIntervalSec); + if (!flowId || !pollToken || !authorizationUrl || expiresInSec <= 0 || pollIntervalSec <= 0) { + throw new Error("Cloud social login response is incomplete"); + } + return { flowId, pollToken, authorizationUrl, expiresInSec, pollIntervalSec }; +} + +function assertProviderAuthorizationUrl(provider: SocialLoginProvider, rawUrl: string): void { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error("Cloud social login authorization URL is invalid"); + } + const expectedHost = provider === "google" ? "accounts.google.com" : "github.com"; + if (url.protocol !== "https:" || url.hostname !== expectedHost) { + throw new Error(`Cloud social login authorization URL is not allowed for ${provider}`); + } +} + +function toCloudSocialLoginStatus(data: Record): CloudSocialLoginStatus { + const status = readString(data.status); + if (status === "pending" || status === "expired") { + return { status }; + } + if (status === "completed") { + return { status, result: toCloudLoginResult(asRecord(data.result)) }; + } + if (status === "failed") { + const code = readString(data.code) ?? undefined; + return { + status, + ...(code ? { code } : {}), + message: readString(data.message) ?? "Social login failed" + }; + } + throw new Error("Cloud social login returned an unknown status"); +} + /** * Builds the cloud account-profile update body. * diff --git a/App/backend/src/adapters/outbound/cloud-client/index.ts b/App/backend/src/adapters/outbound/cloud-client/index.ts index bcb5805d8..72c917b5f 100644 --- a/App/backend/src/adapters/outbound/cloud-client/index.ts +++ b/App/backend/src/adapters/outbound/cloud-client/index.ts @@ -12,6 +12,10 @@ export type { CloudIntegrationSessionInput, CloudLoginInput, CloudLoginResult, + CloudSocialLoginCredentials, + CloudSocialLoginStatus, + CloudStartSocialLoginInput, + CloudStartSocialLoginResult, CloudLogoutInput, GetAccountInfoInput, GetTokenQuotaEligibilityInput, diff --git a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts index 66b83b984..9228c7d09 100644 --- a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts +++ b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts @@ -145,6 +145,112 @@ describe("cloud client", () => { expect(receivedDeviceId).toBeUndefined(); }); + it("starts and polls a Google login without exposing the cloud credential in the authorization URL", async () => { + const requests: Array<{ path: string; body: unknown }> = []; + server = createServer(async (request, response) => { + const body = await readJson(request); + requests.push({ path: request.url ?? "", body }); + if (request.url === "/api/agentUser/oauth/start") { + sendJson(response, { + code: 0, + message: "ok", + data: { + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=opaque-state", + expiresInSec: 600, + pollIntervalSec: 2 + } + }); + return; + } + sendJson(response, { + code: 0, + message: "ok", + data: { + status: "completed", + result: { + id: "1972215566392614914", + email: "hello@example.com", + userName: "hello", + userType: "NEW_USER", + uuid: "cloud.social.login.uuid", + invitationResult: { status: "not_provided" } + } + } + }); + }); + await listen(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Mock cloud server did not bind"); + const client = createHttpCloudClient({ + baseUrl: `http://127.0.0.1:${address.port}`, + timeoutMs: 1000 + }); + + const start = await client.startSocialLogin({ + provider: "google", + locale: "en", + loginSource: "Memmy" + }); + const status = await client.getSocialLoginStatus({ + flowId: start.flowId, + pollToken: start.pollToken + }); + + expect(start.authorizationUrl).not.toContain("cloud.social.login.uuid"); + expect(status).toMatchObject({ + status: "completed", + result: { + uuid: "cloud.social.login.uuid", + isNewUser: true, + profile: { email: "hello@example.com" } + } + }); + expect(requests).toEqual([ + { + path: "/api/agentUser/oauth/start", + body: { provider: "google", locale: "en", loginSource: "memmy" } + }, + { + path: "/api/agentUser/oauth/status", + body: { + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001" + } + } + ]); + }); + + it("rejects a social-login authorization URL outside the provider allowlist", async () => { + server = createServer((_request, response) => { + sendJson(response, { + code: 0, + message: "ok", + data: { + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001", + authorizationUrl: "https://example.com/fake-google-login", + expiresInSec: 600, + pollIntervalSec: 2 + } + }); + }); + await listen(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Mock cloud server did not bind"); + const client = createHttpCloudClient({ + baseUrl: `http://127.0.0.1:${address.port}`, + timeoutMs: 1000 + }); + + await expect(client.startSocialLogin({ + provider: "google", + locale: "en", + loginSource: "Memmy" + })).rejects.toThrow(/not allowed/); + }); + it("sends the international edition through X-Agent-Region", async () => { let receivedRegion: string | undefined; vi.stubEnv("MEMMY_APP_EDITION", "intl"); diff --git a/App/backend/src/adapters/outbound/cloud-client/types.ts b/App/backend/src/adapters/outbound/cloud-client/types.ts index a5568f76c..37a00f448 100644 --- a/App/backend/src/adapters/outbound/cloud-client/types.ts +++ b/App/backend/src/adapters/outbound/cloud-client/types.ts @@ -11,6 +11,7 @@ import type { InvitationResult, OkResponse, PromotionFlags, + SocialLoginProvider, TokenUsageDto } from "@memmy/local-api-contracts"; @@ -65,6 +66,32 @@ export interface CloudLoginResult { invitationResult: InvitationResult; } +export interface CloudStartSocialLoginInput { + provider: SocialLoginProvider; + locale: "zh" | "en"; + loginSource: "Memmy"; + invitationCode?: string; +} + +export interface CloudStartSocialLoginResult { + flowId: string; + pollToken: string; + authorizationUrl: string; + expiresInSec: number; + pollIntervalSec: number; +} + +export interface CloudSocialLoginCredentials { + flowId: string; + pollToken: string; +} + +export type CloudSocialLoginStatus = + | { status: "pending" } + | { status: "completed"; result: CloudLoginResult } + | { status: "failed"; code?: string; message: string } + | { status: "expired" }; + export interface EnsureInvitationCodeInput { uuid: string; } @@ -204,6 +231,8 @@ export interface CloudClient { sendEmailCode(input: SendEmailCodeInput): Promise; sendPhoneCode(input: SendPhoneCodeInput): Promise; login(input: CloudLoginInput): Promise; + startSocialLogin(input: CloudStartSocialLoginInput): Promise; + getSocialLoginStatus(input: CloudSocialLoginCredentials): Promise; ensureInvitationCode(input: EnsureInvitationCodeInput): Promise; logout(input: CloudLogoutInput): Promise; getAccountInfo(input: GetAccountInfoInput): Promise; diff --git a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts index 739d6876e..31dc609a6 100644 --- a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts +++ b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts @@ -4,6 +4,7 @@ import { ApiErrorBodySchema, CloseSessionOutputSchema, CompleteTurnOutputSchema, + SourceTurnCompleteOutputSchema, DeleteMemoryOutputSchema, DeletePanelTaskOutputSchema, EnqueueImportSummariesOutputSchema, @@ -166,6 +167,10 @@ export function createHttpMemoryClient( }); }, + async completeSourceTurn(input, context) { + return request("POST", "completeSourceTurn", SourceTurnCompleteOutputSchema, { body: input, context }); + }, + async search(input, context) { return request("POST", "search", SearchOutputSchema, { body: input, context }); }, diff --git a/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts b/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts index a8b46b335..22a51e0c3 100644 --- a/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts +++ b/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts @@ -9,6 +9,7 @@ export const MEMORY_LAYER_PATHS = Object.freeze({ closeSession: "/api/v1/sessions/:sessionId/close", startTurn: "/api/v1/turns/start", completeTurn: "/api/v1/turns/:turnId/complete", + completeSourceTurn: "/api/v1/source-turns/complete", search: "/api/v1/memory/search", addMemory: "/api/v1/memory/add", getMemory: "/api/v1/memory/:id", diff --git a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts index 75cb6e228..d658e9bb6 100644 --- a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts +++ b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts @@ -12,6 +12,24 @@ afterEach(async () => { }); describe("HttpMemoryClient", () => { + it("posts a native turn without a Runtime Session and preserves pending reasons", async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const client = createHttpMemoryClient({ baseUrl: "http://memory.test", token: "fixture-token", timeoutMs: 500, maxRetries: 0 }, { + fetchImpl: (async (url, init) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ status: "pending", reason: "source_episode_closed" }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch + }); + const input = { + sourceTurn: { source: "codex", profileId: "default", conversationId: "native-session", turnId: "native-turn", startedAt: "2099-01-01T00:00:00.000Z", completedAt: "2099-01-01T00:01:00.000Z", completionEvidence: "final_answer:native-turn" }, + channel: "agent_source_scan" as const, query: "Run tests", answer: "Tests passed", toolCalls: [{ id: "call-a", name: "test", input: "npm test", output: "passed" }] + }; + expect(await client.completeSourceTurn(input, { userId: "fixture-user" })).toEqual({ status: "pending", reason: "source_episode_closed" }); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("http://memory.test/api/v1/source-turns/complete"); + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual(input); + expect(new Headers(calls[0]?.init?.headers).get("x-memmy-user-id")).toBe("fixture-user"); + }); it("only defines path templates for the final memory HTTP APIs", () => { expect(Object.values(MEMORY_LAYER_PATHS)).toEqual([ "/api/v1/health", @@ -22,6 +40,7 @@ describe("HttpMemoryClient", () => { "/api/v1/sessions/:sessionId/close", "/api/v1/turns/start", "/api/v1/turns/:turnId/complete", + "/api/v1/source-turns/complete", "/api/v1/memory/search", "/api/v1/memory/add", "/api/v1/memory/:id", diff --git a/App/backend/src/adapters/outbound/memory-client/tests/mock-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/mock-memory-client.test.ts index f9adf1f02..7e8ea06ed 100644 --- a/App/backend/src/adapters/outbound/memory-client/tests/mock-memory-client.test.ts +++ b/App/backend/src/adapters/outbound/memory-client/tests/mock-memory-client.test.ts @@ -76,6 +76,7 @@ describe("createMockMemoryClient", () => { "addMemory", "closeSession", "completeTurn", + "completeSourceTurn", "deleteMemory", "enqueueImportSummaries", "getMemory", diff --git a/App/backend/src/adapters/outbound/memory-client/types.ts b/App/backend/src/adapters/outbound/memory-client/types.ts index 40040dd8a..8467a6819 100644 --- a/App/backend/src/adapters/outbound/memory-client/types.ts +++ b/App/backend/src/adapters/outbound/memory-client/types.ts @@ -9,6 +9,8 @@ import type { DeletePanelTaskOutput, CompleteTurnInput, CompleteTurnOutput, + SourceTurnCompleteInput, + SourceTurnCompleteOutput, EnqueueImportSummariesOutput, GetMemoryOutput, MemoryApiLogsInput, @@ -52,6 +54,8 @@ export interface MemoryClient { startTurn(input: StartTurnInput, context?: MemoryRequestContext): Promise; completeTurn(input: CompleteTurnInput & { turnId: string }, context?: MemoryRequestContext): Promise; + completeSourceTurn(input: SourceTurnCompleteInput, context?: MemoryRequestContext): Promise; + search(input: SearchInput, context?: MemoryRequestContext): Promise; addMemory(input: AddMemoryInput, context?: MemoryRequestContext): Promise; getMemory(input: { memoryId: string }, context?: MemoryRequestContext): Promise; diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts index 3b3300a15..bc31cdb17 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts @@ -273,7 +273,7 @@ describe("codex skill target", () => { } }); - it("uses turn.complete as the only write phase for a completed Codex turn", async () => { + it("uses native source completion as the only write phase for a completed Codex turn", async () => { const { rootDirectory, memmyConfigPath } = createFixture(); const requests: Array<{ body: Record; path: string }> = []; const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { @@ -296,8 +296,8 @@ describe("codex skill target", () => { }); return; } - if (request.method === "POST" && url.pathname === "/api/v1/turns/turn-stop-1/complete") { - writeJsonResponse(response, 200, { turnId: "turn-stop-1", l1MemoryId: "trace_1" }); + if (request.method === "POST" && url.pathname === "/api/v1/source-turns/complete") { + writeJsonResponse(response, 200, { status: "stored", result: { turnId: "turn-stop-1", l1MemoryId: "trace_1" } }); return; } writeJsonResponse(response, 404, {}); @@ -331,10 +331,18 @@ describe("codex skill target", () => { } }); + const transcriptPath = join(rootDirectory, "rollout-stop.jsonl"); + writeFileSync(transcriptPath, [ + { type: "session_meta", payload: { id: "codex-session-1", cwd: "/tmp/memmy-project" } }, + { type: "event_msg", payload: { type: "task_started", turn_id: "turn-stop-1" } }, + { type: "response_item", payload: { type: "message", role: "user", content: [{ text: "请继续完成数据分析报告" }] } }, + { type: "response_item", payload: { type: "message", role: "assistant", phase: "final_answer", content: [{ text: "已经完成数据分析报告" }] } } + ].map(record => JSON.stringify({ ...record, timestamp: "2026-09-09T10:00:00.000Z" })).join("\n") + "\n"); const run = await runNodeHook( hookScriptPath, JSON.stringify({ hook_event_name: "Stop", + transcript_path: transcriptPath, session_id: "codex-session-1", turn_id: "turn-stop-1", cwd: "/tmp/memmy-project", @@ -349,9 +357,7 @@ describe("codex skill target", () => { "/api/v1/health", "/api/v1/sessions/open", "/api/v1/turns/start", - "/api/v1/health", - "/api/v1/sessions/open", - "/api/v1/turns/turn-stop-1/complete" + "/api/v1/source-turns/complete" ]); expect(requests[1]?.body).toMatchObject({ sessionId: "codex-memory-codex-session-1", @@ -365,9 +371,10 @@ describe("codex skill target", () => { turnId: "turn-stop-1", query: "请继续完成数据分析报告" }); - expect(requests[5]?.body).toMatchObject({ + expect(requests[3]?.body).toMatchObject({ adapterId: "memmy-codex-hook", - requestId: expect.stringMatching(/^codex-complete:turn-stop-1:/u), + channel: "hook", + sourceTurn: expect.objectContaining({ conversationId: "codex-session-1", turnId: "turn-stop-1" }), sessionId: "memmy-session-1", query: "请继续完成数据分析报告", answer: "已经完成数据分析报告", @@ -375,7 +382,7 @@ describe("codex skill target", () => { source: "codex", sourceMemoryIds: ["memory-1"] }); - expect(requests[5]?.body).not.toHaveProperty("episodeId"); + expect(requests[3]?.body).not.toHaveProperty("episodeId"); } finally { await close(server); } diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts index 716677ef6..0167aa71f 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-resume-hook.ts @@ -16,6 +16,8 @@ import { join } from "node:path"; import { closeRuntimeSession, completeRuntimeTurn, + completeSourceTurn, + readCodexSourceTurn, loadRuntimeL3, notifyRuntimeBoundary, openRuntimeSession, @@ -37,6 +39,9 @@ const RESUME_CONTEXT_MAX_CHARS = 24000; async function main() { const input = await readStdin(); const payload = parseJson(input) || {}; + // Cursor also executes hooks inherited from Claude settings. Its own hook + // owns these events; recording them as claude_code would duplicate the turn. + if (MODE === "claude-code" && normalizeText(payload.cursor_version)) return; if (isL3LifecycleEvent(payload)) { try { await handleL3LifecycleEvent(payload); @@ -57,7 +62,8 @@ async function main() { if (isStopEvent(payload)) { try { await captureCompletedTurn(payload); - } catch { + } catch (error) { + reportCaptureFailure("request_failed", error, payload); // Memory capture must not interrupt host turn completion. } writeStopOutput(); @@ -88,7 +94,8 @@ async function main() { try { const started = await startCapturedTurn(payload, prompt); writeTurnStartOutput(started); - } catch { + } catch (error) { + reportCaptureFailure("start_failed", error, payload); writeAllowOutput(); } return; @@ -223,6 +230,10 @@ function isAgentResponseEvent(payload) { } async function captureCompletedTurn(payload) { + if (MODE === "codex") { + await captureCodexSourceTurn(payload); + return; + } const pending = await readTurnState(payload); const status = completedTurnStatus(payload); if (status === "cancelled") { @@ -264,6 +275,59 @@ async function captureCompletedTurn(payload) { await clearTurnState(payload); } +async function captureCodexSourceTurn(payload) { + const status = completedTurnStatus(payload); + if (status === "cancelled") { + await clearTurnState(payload); + return; + } + const transcriptPath = normalizeText(payload.transcript_path || payload.transcriptPath); + if (!transcriptPath) { + reportCaptureFailure("transcript_unavailable", undefined, payload); + return; + } + const pending = await readTurnState(payload); + const expectedTurnId = platformTurnId(payload); + const expectedConversationId = normalizeText(payload.session_id || payload.sessionId || payload.conversation_id || payload.conversationId || payload.thread_id || payload.threadId); + const parsed = await readCodexSourceTurn(transcriptPath, { + turnId: expectedTurnId || undefined, + conversationId: expectedConversationId || undefined, + stop: status === "succeeded" + }); + if (!parsed.turn) { + reportCaptureFailure(parsed.reason || "identity_unresolved", undefined, payload); + return; + } + if (status === "failed" && parsed.turn.status !== "failed") { + reportCaptureFailure("turn_status_unresolved", undefined, payload); + return; + } + if (isResumeCommand(parsed.turn.query)) { + await clearTurnState(payload); + return; + } + const result = await completeSourceTurn({ + configUrl: CONFIG_URL, + turn: parsed.turn, + sessionId: normalizeText(pending && pending.sessionId) || undefined, + sourceMemoryIds: Array.isArray(pending && pending.sourceMemoryIds) ? pending.sourceMemoryIds : undefined + }); + if (result.status === "stored" || result.status === "existing" || result.status === "rejected") { + await clearTurnState(payload); + return; + } + reportCaptureFailure(normalizeText(result.reason) || normalizeText(result.status) || "unexpected_response", undefined, payload); +} + +function reportCaptureFailure(reason, error, payload = {}) { + process.stderr.write(JSON.stringify({ + event: "memmy.hook.capture_failed", source: SOURCE, reason, + sourceSessionId: sessionStateKey(payload), sourceTurnId: platformTurnId(payload) || undefined, + transcriptPath: normalizeText(payload.transcript_path || payload.transcriptPath) || undefined, + error: error ? formatError(error) : undefined + }) + "\n"); +} + async function startCapturedTurn(payload, prompt) { const query = sanitizeCaptureText(prompt); if (!query) { diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts b/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts index c35a15426..382a7ac86 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts @@ -1,11 +1,12 @@ import { spawn } from "node:child_process"; -import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { loadMemmyWorkspaceBridgeRuntimeAsset } from "../../workspace-bridge/runtime-loader.js"; import { renderMemmyResumeHookScript } from "../memmy-resume-hook.js"; +import { renderMemmyResumeHookScript as renderCliHook } from "../../../../../../../../Memory/src/agent-source/integration/templates/memmy-resume-hook.js"; describe("memmy resume hook stop capture", () => { let tempDir = ""; @@ -22,6 +23,107 @@ describe("memmy resume hook stop capture", () => { } }); + it.each([ + ["desktop", renderMemmyResumeHookScript], + ["memory CLI", renderCliHook], + ])("%s Claude hook ignores inherited Cursor events without requests or state writes", async (_label, render) => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-inherited-cursor-hook-")); + const paths: string[] = []; + const server = createServer((request, response) => { + paths.push(request.url ?? ""); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ sessionId: "unexpected-session" })); + }); + await listen(server); + try { + const endpoint = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + const script = installHookFixture(tempDir, "claude_code", "claude-code", endpoint, runtimeAsset, render); + const filesBefore = readDirectory(tempDir).sort(); + // Cursor can pass through its own event names or map them to Claude names. + for (const event of ["sessionStart", "beforeSubmitPrompt", "afterAgentResponse", "stop", "preCompact", "sessionEnd", + "SessionStart", "UserPromptSubmit", "Stop", "PostCompact", "SessionEnd"]) { + const result = await runHook(script, { + hook_event_name: event, cursor_version: "3.17.19", session_id: "cursor-session", + conversation_id: "cursor-session", generation_id: "cursor-turn", + prompt: "Explain branch and worktree", text: "A worktree is a separate checkout", + last_assistant_message: "A worktree is a separate checkout", cwd: tempDir, + }); + expect(result).toEqual({ status: 0, stdout: "", stderr: "" }); + } + expect(paths).toEqual([]); + expect(readDirectory(tempDir).sort()).toEqual(filesBefore); + } finally { + await close(server); + } + }, 30000); + + it.each([ + ["desktop", renderMemmyResumeHookScript], + ["memory CLI", renderCliHook], + ])("%s captures a Cursor turn once when both hooks run, and still captures native Claude turns", async (_label, render) => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-dual-cursor-hook-")); + const requests: Array<{ path: string; body: Record }> = []; + const server = createServer(async (request, response) => { + const body = await requestBody(request); + requests.push({ path: request.url ?? "", body }); + response.setHeader("content-type", "application/json"); + if (request.url === "/api/v1/health") { + response.end(JSON.stringify({ features: { l3WorldModelProtocolVersions: [2] } })); + } else if (request.url === "/api/v1/sessions/open") { + response.end(JSON.stringify({ sessionId: `session-${(body.namespace as { source: string }).source}` })); + } else if (request.url === "/api/v1/turns/start") { + response.end(JSON.stringify({ sessionId: body.sessionId, turnId: body.turnId, episodeId: `episode-${body.sessionId}` })); + } else { + response.end(JSON.stringify({ ok: true })); + } + }); + await listen(server); + try { + const endpoint = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + const scripts = ["cursor", "claude"].map(name => { + const directory = join(tempDir, name); + mkdirSync(directory); + return installHookFixture(directory, name === "cursor" ? "cursor" : "claude_code", + name === "cursor" ? "cursor" : "claude-code", endpoint, runtimeAsset, render); + }); + const payload = { + cursor_version: "3.17.19", session_id: "cursor-conversation", conversation_id: "cursor-conversation", + generation_id: "cursor-turn", prompt: "Explain branch and worktree", cwd: tempDir, + }; + for (const event of ["beforeSubmitPrompt", "afterAgentResponse", "stop"]) { + for (const script of scripts) { + const result = await runHook(script, { ...payload, hook_event_name: event, + text: "A worktree is a separate checkout", last_assistant_message: "A worktree is a separate checkout" }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + } + } + const completions = () => requests.filter(request => request.path.endsWith("/complete")); + expect(completions()).toHaveLength(1); + expect(completions()[0]?.body).toMatchObject({ + namespace: { source: "cursor" }, query: payload.prompt, answer: "A worktree is a separate checkout", + }); + expect(requests.filter(request => request.path === "/api/v1/turns/start")).toHaveLength(1); + expect(requests.some(request => request.body.namespace?.source === "claude_code")).toBe(false); + + // Host detection uses event provenance, not inherited terminal environment. + for (const event of ["UserPromptSubmit", "Stop"]) { + const result = await runHook(scripts[1]!, { + hook_event_name: event, session_id: "native-claude-session", turn_id: "claude-turn", + prompt: "Explain native Claude capture", last_assistant_message: "Captured by Claude only", cwd: tempDir, + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + } + expect(completions()).toHaveLength(2); + expect(completions()[1]?.body).toMatchObject({ + namespace: { source: "claude_code" }, query: "Explain native Claude capture", answer: "Captured by Claude only", + }); + } finally { + await close(server); + } + }, 30000); + it("captures the user prompt instead of the last tool result when turn state is missing", async () => { tempDir = mkdtempSync(join(tmpdir(), "memmy-resume-hook-stop-")); const requests: Array<{ path: string; body: Record }> = []; @@ -244,9 +346,10 @@ function installHookFixture( mode: "claude-code" | "codex" | "cursor", endpoint: string, runtimeAsset: string, + render = renderMemmyResumeHookScript, ): string { const hookScriptPath = join(directory, "memmy-resume-hook.mjs"); - writeFileSync(hookScriptPath, renderMemmyResumeHookScript({ source, mode })); + writeFileSync(hookScriptPath, render({ source, mode })); writeFileSync(join(directory, "memmy-workspace-bridge.mjs"), runtimeAsset); writeFileSync(join(directory, "memmy-memory-config.json"), JSON.stringify({ memmy_config_path: join(directory, "missing-config.yaml"), diff --git a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts index b8bc695bd..604c72e99 100644 --- a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts @@ -5,7 +5,9 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { loadMemmyWorkspaceBridgeRuntimeAsset } from "./runtime-loader.js"; +import type { SourceTurn } from "@memmy/agent-source-core"; import { + completeSourceTurn, notifyRuntimeBoundary, openRuntimeSession, readRuntimeConfig, @@ -48,6 +50,47 @@ describe("Memory lifecycle runtime", () => { }); }); + it.each([undefined, "pending-session"])("submits the pinned owner with pending Session %s across account changes", async (sessionId) => { + const fixture = createFixture(); + const requests: Array<{ path: string; body: Record }> = []; + const server = createServer(async (request, response) => { + requests.push({ path: request.url ?? "", body: await requestBody(request) }); + return json(response, 200, { status: "stored" }); + }); + const endpoint = await listen(server); + const configUrl = runtimeConfig(fixture, endpoint); + const turn: SourceTurn = { + source: "codex", conversationId: "native-conversation", turnId: "native-turn", + startedAt: "2026-09-09T10:00:00.000Z", completedAt: "2026-09-09T10:00:01.000Z", + sequence: 1, completionEvidence: "final_answer:native-turn", query: "Fix parser", answer: "Fixed", + status: "succeeded", toolCalls: [], toolResults: [], workspacePath: fixture, + }; + try { + await completeSourceTurn({ configUrl, turn, sessionId, profileId: "work", sourceMemoryIds: ["recalled-1"] }); + writeFileSync(join(fixture, "missing.yaml"), [ + "app:", " userId: switched-app-owner", "memmyMemory:", " userId: switched-memory-owner", "", + ].join("\n")); + await completeSourceTurn({ configUrl, turn, sessionId, profileId: "work", sourceMemoryIds: ["recalled-1"] }); + + expect(requests).toHaveLength(2); + expect(requests[1]).toEqual(requests[0]); + for (const request of requests) { + expect(request.path).toBe("/api/v1/source-turns/complete"); + expect(request.body.namespace).toEqual({ + source: "codex", profileId: "work", userId: "installed-owner", sessionKey: "native-conversation", + }); + expect(request.body).toMatchObject({ + sourceTurn: { source: "codex", profileId: "work", conversationId: "native-conversation" }, + sourceMemoryIds: ["recalled-1"], workspacePath: fixture, + }); + if (sessionId) expect(request.body.sessionId).toBe(sessionId); + else expect(request.body).not.toHaveProperty("sessionId"); + } + } finally { + await close(server); + } + }); + it("opens a v2 project Session with only canonical workspace identity", async () => { const fixture = createFixture(); const requests: Array<{ path: string; body: Record }> = []; diff --git a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts index 0a8e13b78..8306cd2dc 100644 --- a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts +++ b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts @@ -1,3 +1,5 @@ +import { buildSourceTurnRequest, type SourceTurn } from "@memmy/agent-source-core"; +export { readCodexSourceTurn } from "@memmy/agent-source-core"; import { createHash, randomUUID } from "node:crypto"; import { lstat, readFile, realpath, stat } from "node:fs/promises"; import { homedir } from "node:os"; @@ -232,6 +234,31 @@ export async function completeRuntimeTurn( await client.post(`/api/v1/turns/${encodeURIComponent(input.turnId)}/complete`, compact(body)); } +/** Submit a completed native turn without opening a new runtime Session before deduplication. */ +export async function completeSourceTurn(input: { + configUrl: URL; + turn: SourceTurn; + sessionId?: string; + sourceMemoryIds?: string[]; + profileId?: string; +}): Promise> { + const config = await readRuntimeConfig(input.configUrl, true); + const client = new RuntimeHttpClient(config); + const profileId = input.profileId || "default"; + return objectValue(await client.post("/api/v1/source-turns/complete", compact({ + ...buildSourceTurnRequest(input.turn, "hook", profileId), + namespace: { + source: input.turn.source, + profileId, + userId: config.userId, + sessionKey: input.turn.conversationId, + }, + sessionId: input.sessionId, + sourceMemoryIds: input.sourceMemoryIds, + adapterId: "memmy-codex-hook", + }))); +} + class RuntimeHttpClient { constructor(private readonly config: RuntimeConfig) {} diff --git a/App/backend/src/infrastructure/agent-source-scan-store/index.ts b/App/backend/src/infrastructure/agent-source-scan-store/index.ts index 5a44dfef2..574cd5301 100644 --- a/App/backend/src/infrastructure/agent-source-scan-store/index.ts +++ b/App/backend/src/infrastructure/agent-source-scan-store/index.ts @@ -96,13 +96,22 @@ export function openAppAgentSourceScanStore(path: string, job: AppScanJobMeta): } let ordinal = Number((db.prepare("SELECT COALESCE(MAX(ordinal), -1) AS value FROM staged_messages WHERE job_id=?").get(job.jobId) as { value: number }).value) + 1; const insert = db.prepare("INSERT OR IGNORE INTO staged_messages(job_id,source_id,conversation_id,message_id,role,content,created_at,workspace_path,git_root,raw_meta_json,ordinal) VALUES(?,?,?,?,?,?,?,?,?,?,?)"); + const refreshCodex = db.prepare(`UPDATE staged_messages + SET conversation_id=?,role=?,content=?,created_at=?,workspace_path=?,git_root=?,raw_meta_json=? + WHERE job_id=? AND source_id=? AND message_id=?`); const store: AppAgentSourceScanStore = { path, stage(message) { const bytes = Buffer.byteLength(JSON.stringify(message)); if (bytes > MAX_RECORD_BYTES) throw new Error(`scan record exceeds 64 MiB limit (${bytes} bytes)`); - const result = insert.run(job.jobId, message.sourceId, message.conversationId, message.messageId, message.role, message.content, message.createdAt, message.workspacePath, message.gitRoot, JSON.stringify(message.rawMeta), ordinal++); - return Number(result.changes) > 0; + const rawMetaJson = JSON.stringify(message.rawMeta); + const inserted = Number(insert.run(job.jobId, message.sourceId, message.conversationId, message.messageId, message.role, message.content, message.createdAt, message.workspacePath, message.gitRoot, rawMetaJson, ordinal++).changes) > 0; + if (!inserted && message.sourceId === "codex" && typeof message.rawMeta.sourceTurnState === "string") { + // Retrying a staged turn can add native identity or completion evidence + // to an existing message. Preserve its ordinal and the insertion count. + refreshCodex.run(message.conversationId, message.role, message.content, message.createdAt, message.workspacePath, message.gitRoot, rawMetaJson, job.jobId, message.sourceId, message.messageId); + } + return inserted; }, stageBatch(messages) { let inserted = 0; diff --git a/App/backend/src/infrastructure/agent-source-scan-store/tests/repository.test.ts b/App/backend/src/infrastructure/agent-source-scan-store/tests/repository.test.ts index 1c3076700..65fb68bb7 100644 --- a/App/backend/src/infrastructure/agent-source-scan-store/tests/repository.test.ts +++ b/App/backend/src/infrastructure/agent-source-scan-store/tests/repository.test.ts @@ -8,6 +8,45 @@ let directory: string | undefined; afterEach(() => { if (directory) rmSync(directory, { recursive: true, force: true }); directory = undefined; }); describe("durable scan store", () => { + it("refreshes unresolved Codex rows in the same job without counting them as new messages", () => { + directory = mkdtempSync(join(tmpdir(), "memmy-codex-scan-retry-")); + const path = join(directory, "job.sqlite"); + const job = { jobId: "job", sourceId: "codex", mode: "incremental", phase: "failed", createdAt: "2026-09-09", updatedAt: "2026-09-09" }; + let store = openAppAgentSourceScanStore(path, job); + const pending = { messageId: "rollout:000000000009", sourceId: "codex", conversationId: "fallback-conversation", role: "assistant" as const, content: "Draft", createdAt: "2026-09-09T00:00:00Z", workspacePath: null, gitRoot: null, rawMeta: { sourceTurnState: "identity_unresolved", sourceTurnReason: "identity_unresolved" } }; + expect(store.stage(pending)).toBe(true); + const stagedOrdinal = [...store.messages("codex")][0]!.ordinal; + store.saveResult({ sourceId: "codex", conversationId: pending.conversationId, error: "identity_unresolved" }); + store.close(); + store = openAppAgentSourceScanStore(path, job); + const completed = { ...pending, conversationId: "native-conversation", content: "Final answer", workspacePath: "/tmp/project", rawMeta: { sourceTurnState: "complete", sourceTurnId: "turn-native", sourceTurn: { turnId: "turn-native", completionEvidence: "task_complete:turn-native" } } }; + const next = { ...completed, messageId: "rollout:000000000010", content: "Next answer" }; + expect(store.stageBatch([completed, next])).toBe(1); + expect(store.stage(completed)).toBe(false); + expect(store.count("codex")).toBe(2); + const rows = [...store.messages("codex")]; + expect(rows[0]).toMatchObject({ ...completed, ordinal: stagedOrdinal }); + expect(rows[0]!.rawMeta).not.toHaveProperty("sourceTurnReason"); + expect([...store.results("codex")]).toEqual([{ sourceId: "codex", conversationId: pending.conversationId, error: "identity_unresolved" }]); + store.close(); + store = openAppAgentSourceScanStore(path, job); + expect([...store.messages("codex")][0]).toMatchObject(completed); + store.close(); + }); + + it("keeps existing non-Codex staged rows and unrelated rows unchanged", () => { + directory = mkdtempSync(join(tmpdir(), "memmy-scan-legacy-dedup-")); + const store = openAppAgentSourceScanStore(join(directory, "job.sqlite"), { jobId: "job", sourceId: "all", mode: "full", phase: "stage", createdAt: "2026-09-09", updatedAt: "2026-09-09" }); + const message = { messageId: "shared-id", sourceId: "fixture", conversationId: "conversation", role: "user" as const, content: "Original", createdAt: "2026-09-09T00:00:00Z", workspacePath: null, gitRoot: null, rawMeta: {} }; + const codex = { ...message, sourceId: "codex", rawMeta: { sourceTurnState: "turn_incomplete" } }; + expect(store.stageBatch([message, codex])).toBe(2); + expect(store.stage({ ...message, content: "Revised" })).toBe(false); + expect(store.stage({ ...codex, content: "Complete", rawMeta: { sourceTurnState: "complete" } })).toBe(false); + expect([...store.messages("fixture")][0]!.content).toBe("Original"); + expect([...store.messages("codex")][0]!.content).toBe("Complete"); + expect(store.count()).toBe(2); + store.close(); + }); it("deduplicates staged rows and reads keyset pages", () => { directory = mkdtempSync(join(tmpdir(), "memmy-scan-store-")); const store = openAppAgentSourceScanStore(join(directory, "job.sqlite"), { jobId: "job", sourceId: "fixture", mode: "full", phase: "stage", createdAt: "2026-01-01", updatedAt: "2026-01-01" }); diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index 93c0e3ff1..d38d8cb16 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -384,6 +384,10 @@ export function mapModelProtocol(provider: ModelProvider): ModelProtocolProjecti return { agentProvider: "qianfan", agentApiType: "auto", memoryProvider: "openai_compatible" }; case "doubao": return { agentProvider: "volcengine", agentApiType: "auto", memoryProvider: "openai_compatible" }; + case "stepfun": + return { agentProvider: "stepfun", agentApiType: "auto", memoryProvider: "openai_compatible" }; + case "xiaomi": + return { agentProvider: "xiaomi_mimo", agentApiType: "auto", memoryProvider: "openai_compatible" }; } } diff --git a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts index 9050c3004..c1d25dc37 100644 --- a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts +++ b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts @@ -1,5 +1,6 @@ import { BUILTIN_LOCAL_EMBEDDING_ASSIGNMENT_ID, + CatalogProviderIdSchema, type CatalogEndpointInput, type CatalogProviderId, type ModelAssignment, @@ -854,9 +855,7 @@ function isStringRecord(value: unknown): value is Record { } function isCatalogProviderId(value: unknown): value is CatalogProviderId { - return typeof value === "string" && [ - "openai", "anthropic", "gemini", "deepseek", "zhipu", "dashscope", "moonshot", "minimax", "qianfan", "volcengine", ACCOUNT_PROVIDER - ].includes(value); + return CatalogProviderIdSchema.safeParse(value).success; } function isEndpointProtocol(value: unknown): value is ModelEndpointProtocol { diff --git a/App/backend/src/infrastructure/memmy-config/tests/index.test.ts b/App/backend/src/infrastructure/memmy-config/tests/index.test.ts index f9d2ed884..e9574367b 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/index.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/index.test.ts @@ -179,7 +179,8 @@ describe("memmy runtime config current contract", () => { await writeAppCloudUuidToMemmyConfig("cloud-token", target); await expect(readRuntimeMemmyConfigState(target)).resolves.toMatchObject({ status: "valid_account", cloudUuid: "cloud-token" }); expect(Object.fromEntries([ - "openai_compatible", "anthropic", "google", "deepseek", "zhipu", "qwen", "kimi", "minimax", "baidu", "doubao" + "openai_compatible", "anthropic", "google", "deepseek", "zhipu", "qwen", "kimi", "minimax", "baidu", "doubao", + "stepfun", "xiaomi" ].map((provider) => [provider, mapModelProtocol(provider as any).agentProvider]))).toEqual({ openai_compatible: "openai", anthropic: "anthropic", @@ -190,7 +191,19 @@ describe("memmy runtime config current contract", () => { kimi: "moonshot", minimax: "minimax", baidu: "qianfan", - doubao: "volcengine" + doubao: "volcengine", + stepfun: "stepfun", + xiaomi: "xiaomi_mimo" + }); + expect(mapModelProtocol("stepfun")).toEqual({ + agentProvider: "stepfun", + agentApiType: "auto", + memoryProvider: "openai_compatible" + }); + expect(mapModelProtocol("xiaomi")).toEqual({ + agentProvider: "xiaomi_mimo", + agentApiType: "auto", + memoryProvider: "openai_compatible" }); }); }); diff --git a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts index 5bf114bbc..4515c5ab6 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts @@ -69,6 +69,63 @@ function openAiInput(revision: string, presetId?: string): ModelConfigInput { } describe("model config catalog", () => { + it("round-trips StepFun and Xiaomi MiMo connections back into the view", async () => { + const file = fixture({ modelAssignments: emptyAssignments() }); + const current = await readModelConfigCatalog(file); + + const saved = await writeModelConfigCatalog(file, { + configRevision: current.configRevision, + providers: [ + { + provider: "stepfun", + apiKey: "sk-stepfun-secret", + endpoints: [{ + endpointId: "chat", + apiBase: "https://api.stepfun.com/v1", + protocol: "openai-chat-completions" + }], + models: [{ + endpointId: "chat", + model: "step-3.5-flash", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"] + }] + }, + { + provider: "xiaomi_mimo", + apiKey: "sk-mimo-secret", + endpoints: [{ + endpointId: "chat", + apiBase: "https://api.xiaomimimo.com/v1", + protocol: "openai-chat-completions" + }], + models: [{ + endpointId: "chat", + model: "mimo-v2.5-pro", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"] + }] + } + ], + modelAssignments: emptyAssignments() + }); + + // The desktop client matches the model it just saved by provider/endpoint/model, + // so a provider missing from the view surfaces as "Unable to resolve server preset". + const savedModels = saved.providers.flatMap((provider) => provider.models); + expect(savedModels.map((model) => [model.provider, model.model])).toEqual( + expect.arrayContaining([ + ["stepfun", "step-3.5-flash"], + ["xiaomi_mimo", "mimo-v2.5-pro"] + ]) + ); + + const reread = await readModelConfigCatalog(file); + expect(reread.providers.map((provider) => provider.provider)).toEqual( + expect.arrayContaining(["stepfun", "xiaomi_mimo"]) + ); + }); + it("persists the reserved built-in local Embedding assignment without a preset", async () => { const file = fixture({ modelAssignments: emptyAssignments() }); const current = await readModelConfigCatalog(file); diff --git a/App/backend/src/load-env.ts b/App/backend/src/load-env.ts index c3f831ac6..c6397e99e 100644 --- a/App/backend/src/load-env.ts +++ b/App/backend/src/load-env.ts @@ -47,9 +47,13 @@ export function loadCloudServiceEnv(options: LoadCloudServiceEnvOptions = {}): s if (!existsSync(options.manifestPath)) { throw new Error("Packaged desktop runtime manifest is missing"); } - env.MEMMY_CLOUD_SERVICE = cloudServiceFromDesktopRuntimeManifest( + const manifestService = cloudServiceFromDesktopRuntimeManifest( readFileSync(options.manifestPath, "utf8"), ); + if (!manifestService.startsWith("https://")) { + throw new Error("Packaged desktop runtime manifest cloud service must use HTTPS"); + } + env.MEMMY_CLOUD_SERVICE = manifestService; return options.manifestPath; } diff --git a/App/backend/src/project-version.ts b/App/backend/src/project-version.ts index 0930d358d..3999aac79 100644 --- a/App/backend/src/project-version.ts +++ b/App/backend/src/project-version.ts @@ -1,2 +1,2 @@ /** Generated from the root package.json by scripts/sync-project-version.mjs. */ -export const MEMMY_VERSION = "1.1.5"; +export const MEMMY_VERSION = "1.1.6"; diff --git a/App/backend/src/services/account-service.ts b/App/backend/src/services/account-service.ts index 67092ae46..99beb8c5c 100644 --- a/App/backend/src/services/account-service.ts +++ b/App/backend/src/services/account-service.ts @@ -5,6 +5,8 @@ import { AccountProfileViewSchema, AccountSessionViewSchema, SendCodeResponseSchema, + SocialLoginStatusResponseSchema, + StartSocialLoginResponseSchema, type AccountChannel, type AccountInvitationView, type AccountLoginResultView, @@ -12,10 +14,14 @@ import { type AccountSessionView, type SendCodeInput, type SendCodeResponse, + type SocialLoginStatusInput, + type SocialLoginStatusResponse, + type StartSocialLoginInput, + type StartSocialLoginResponse, type UpdateAccountProfileInput, type VerifyCodeInput } from "@memmy/local-api-contracts"; -import type { CloudAccountProfile, CloudClient } from "../adapters/outbound/cloud-client/index.js"; +import type { CloudAccountProfile, CloudClient, CloudLoginResult } from "../adapters/outbound/cloud-client/index.js"; import type { AccountSessionProfileInput, AccountSessionRepository @@ -30,6 +36,8 @@ const RESEND_WINDOW_MS = 60_000; export interface AccountService { sendCode(input: SendCodeInput): Promise; verifyCode(input: VerifyCodeInput): Promise; + startSocialLogin(input: StartSocialLoginInput): Promise; + getSocialLoginStatus(input: SocialLoginStatusInput): Promise; getInvitation(): Promise; updateProfile(input: UpdateAccountProfileInput): Promise; markGuideFinished(): Promise; @@ -94,34 +102,25 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco loginSource: input.loginSource, ...(input.invitationCode ? { invitationCode: input.invitationCode } : {}) }); + return finalizeCloudLogin(loginResult, input.channel, options); + }, - if (options.memmyConfigWriter) { - const projection = await options.memmyConfigWriter.writeAccountModelProjection({ - cloudUuid: loginResult.uuid, - userId: loginResult.profile.userId - }); - await reloadMemoryConfigIfNeeded(projection, options); - } - - const session = AccountSessionViewSchema.parse( - options.accountSessionRepository.upsert({ - profile: toSessionProfileInput(loginResult.profile), - uuid: loginResult.accountUuid, - cloudUuid: loginResult.uuid, - isNewUser: loginResult.isNewUser, - authChannel: input.channel - }) + async startSocialLogin(input) { + assertSocialLoginSupported(options.accountChannel); + return StartSocialLoginResponseSchema.parse( + await options.cloudClient.startSocialLogin(input) ); + }, - const refreshedSession = await refreshCloudGuideState({ - cloudClient: options.cloudClient, - accountSessionRepository: options.accountSessionRepository, - session, - cloudUuid: loginResult.uuid - }); - return AccountLoginResultViewSchema.parse({ - session: refreshedSession, - invitationResult: loginResult.invitationResult ?? { status: "not_provided" } + async getSocialLoginStatus(input) { + assertSocialLoginSupported(options.accountChannel); + const status = await options.cloudClient.getSocialLoginStatus(input); + if (status.status !== "completed") { + return SocialLoginStatusResponseSchema.parse(status); + } + return SocialLoginStatusResponseSchema.parse({ + status: "completed", + result: await finalizeCloudLogin(status.result, "email", options) }); }, @@ -225,6 +224,48 @@ function assertExpectedAccountChannel( }); } +function assertSocialLoginSupported(accountChannel: AccountChannel | undefined): void { + if (accountChannel === "email") return; + throw Object.assign(new Error("Social login is not supported by this desktop package"), { + code: "invalid_argument" as const + }); +} + +async function finalizeCloudLogin( + loginResult: CloudLoginResult, + authChannel: AccountChannel, + options: CreateAccountServiceOptions +): Promise { + if (options.memmyConfigWriter) { + const projection = await options.memmyConfigWriter.writeAccountModelProjection({ + cloudUuid: loginResult.uuid, + userId: loginResult.profile.userId + }); + await reloadMemoryConfigIfNeeded(projection, options); + } + + const session = AccountSessionViewSchema.parse( + options.accountSessionRepository.upsert({ + profile: toSessionProfileInput(loginResult.profile), + uuid: loginResult.accountUuid, + cloudUuid: loginResult.uuid, + isNewUser: loginResult.isNewUser, + authChannel + }) + ); + + const refreshedSession = await refreshCloudGuideState({ + cloudClient: options.cloudClient, + accountSessionRepository: options.accountSessionRepository, + session, + cloudUuid: loginResult.uuid + }); + return AccountLoginResultViewSchema.parse({ + session: refreshedSession, + invitationResult: loginResult.invitationResult ?? { status: "not_provided" } + }); +} + async function reloadMemoryConfigIfNeeded( projection: RuntimeProjectionResult | undefined, options: CreateAccountServiceOptions diff --git a/App/backend/src/services/agent-source-scan-process.ts b/App/backend/src/services/agent-source-scan-process.ts index 7dd0898f0..6f48273c5 100644 --- a/App/backend/src/services/agent-source-scan-process.ts +++ b/App/backend/src/services/agent-source-scan-process.ts @@ -105,13 +105,14 @@ function createAgentSources(appStateStore: AppStateStore, memoryClient: MemoryCl const mode = appStateStore.repositories.bootstrap.getAppSettings().userMode; return mode === "account" || mode === "byok" ? mode : null; }; + const memoryAddAnalytics = createMemoryDesktopAddAnalytics({ + getUserId: resolveAnalyticsUserId, + getUserMode: resolveAnalyticsUserMode, + }); const ingestionService = createIngestionService({ memoryClient, agentSourceRepository: appStateStore.repositories.agentSources, - memoryAddAnalytics: createMemoryDesktopAddAnalytics({ - getUserId: resolveAnalyticsUserId, - getUserMode: resolveAnalyticsUserMode, - }), + memoryAddAnalytics, }); return createAgentSourceService({ @@ -127,6 +128,7 @@ function createAgentSources(appStateStore: AppStateStore, memoryClient: MemoryCl getUserId: resolveAnalyticsUserId, getUserMode: resolveAnalyticsUserMode, }), + memoryAddAnalytics, }); } diff --git a/App/backend/src/services/agent-source-service.ts b/App/backend/src/services/agent-source-service.ts index da0e5971a..62bd1f76d 100644 --- a/App/backend/src/services/agent-source-service.ts +++ b/App/backend/src/services/agent-source-service.ts @@ -39,6 +39,10 @@ import { type AgentSourceInstallType, type AgentSourceLifecycleAnalytics, } from "../analytics/agent-source-analytics.js"; +import type { + MemoryDesktopAddAnalytics, + MemoryDesktopAddScanMode +} from "../analytics/memory-add-analytics.js"; import { errorCodeFromUnknown } from "../analytics/analytics-transport.js"; import { extractManagedAgentHistory, @@ -46,6 +50,9 @@ import { } from "./managed-agent-history.js"; import { orderedTurns, + sourceTurnFromMessages, + sourceTurnFailureReason, + buildSourceTurnRequest, renderTurnClipped, stableTurnIdentity, isCompleteTurn, @@ -114,9 +121,13 @@ export interface CreateAgentSourceServiceOptions { sourceRegistry: SourceRegistry; agentSourceRepository: AgentSourceRepository; ingestionService: IngestionService; - memoryClient: Pick; + memoryClient: Pick; skillDistributionService: SkillDistributionService; agentSourceAnalytics?: AgentSourceLifecycleAnalytics; + memoryAddAnalytics?: Pick< + MemoryDesktopAddAnalytics, + "trackAddStarted" | "trackAddSucceeded" | "trackAddFailed" + >; getScanPermission?: () => Promise; now?: () => string; createId?: () => string; @@ -139,7 +150,7 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption if (!scanOptions.scanJobId && !options.scanStoreDirectory) { const collected = await this.collectAll(scanOptions); const results = await this.ingestCollected(collected, scanOptions); - const failures = await this.processImportSummaries(results.flatMap((result) => result.memoryIds ?? []), { ...scanOptions, progressSourceId: "all" }); + const failures = await this.processImportSummaries(results.filter((result) => result.sourceId !== "codex").flatMap((result) => result.memoryIds ?? []), { ...scanOptions, progressSourceId: "all" }); appendProcessingFailuresToResults(results, failures); return results; } @@ -177,7 +188,7 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption if (!scanOptions.scanJobId && !options.scanStoreDirectory) { const collected = await this.collectOne(sourceId, scanOptions); const result = await ingestCollectedSource(options, collected, scanOptions, now); - const failures = await processPendingImportSummaries(options, result.memoryIds ?? [], { ...scanOptions, progressSourceId: sourceId }); + const failures = sourceId === "codex" ? [] : await processPendingImportSummaries(options, result.memoryIds ?? [], { ...scanOptions, progressSourceId: sourceId }); appendProcessingFailures(result, failures); return result; } @@ -738,7 +749,7 @@ async function preparePersistentSource(options: CreateAgentSourceServiceOptions, let first = true; let latest: ConversationMessage | null = null; const flushTurn = () => { - if (!currentTurn.length || !isCompleteTurn(currentTurn)) return; + if (!currentTurn.length || (sourceId !== "codex" && !isCompleteTurn(currentTurn))) return; const firstMessage = currentTurn[0]!; const lastMessage = currentTurn[currentTurn.length - 1]!; const turn = { sourceId, conversationId: firstMessage.conversationId, turnIndex, messages: currentTurn }; @@ -781,14 +792,14 @@ async function preparePersistentSource(options: CreateAgentSourceServiceOptions, hash.update("["); first = true; } - if (message.role === "user" && currentTurn.length > 0) { + if (currentTurn.length > 0 && (sourceId === "codex" ? message.rawMeta.sourceTurnId !== currentTurn[0]?.rawMeta.sourceTurnId : message.role === "user")) { flushTurn(); currentTurn = []; } currentTurn.push(message); if (!first) hash.update(","); first = false; - hash.update(JSON.stringify({ messageId: message.messageId, role: message.role, content: message.content, createdAt: message.createdAt, toolName: hashMetaString(message, "toolName") ?? hashMetaString(message, "hermesToolName"), toolCallId: hashMetaString(message, "toolCallId") ?? hashMetaString(message, "hermesToolCallId") })); + hash.update(JSON.stringify({ messageId: message.messageId, role: message.role, content: message.content, createdAt: message.createdAt, toolName: hashMetaString(message, "toolName") ?? hashMetaString(message, "hermesToolName"), toolCallId: hashMetaString(message, "toolCallId") ?? hashMetaString(message, "hermesToolCallId"), ...(sourceId === "codex" ? { sourceTurn: message.rawMeta } : {}) })); latest = message; } const last = page[page.length - 1]!; @@ -859,7 +870,41 @@ async function ingestPersistentSource( if (conversationMeta?.selected === false) continue; const selectedTurn = store.getTurnMeta(sourceId, turn.conversationId, stableTurnIdentity(turn)); if (selectedTurn && !selectedTurn.selected) continue; + const scanMode = persistentScanMode(store.getSourceState(sourceId)?.mode ?? scanOptions.mode); + if (sourceId === "codex") { + try { + const sourceTurn = sourceTurnFromMessages(turn.messages); + if (!sourceTurn) throw new Error(sourceTurnFailureReason(turn.messages)); + const result = await options.memoryClient.completeSourceTurn(buildSourceTurnRequest(sourceTurn, "agent_source_scan")); + if (result.status === "pending" || result.status === "conflict") throw new Error(result.reason ?? result.status); + const ids = result.result?.l1MemoryIds ?? []; + if (result.status === "stored") { + memoryIdCount += ids.length; + memoryIds.push(...ids.slice(0, Math.max(0, 1000 - memoryIds.length))); + } else { + deduped += turn.messages.length; + } + // The Memory transaction already registered normal capture jobs. Do not enqueue import summaries. + if (ids.length === 0) store.saveResult({ sourceId, conversationId: turn.conversationId }); + for (const memoryId of ids) store.saveResult({ sourceId, conversationId: turn.conversationId, memoryId }); + } catch (error) { + activeConversationFailed = true; + const reason = error instanceof Error ? error.message : "native turn ingestion failed"; + errorCount += 1; + if (errors.length < 1000) errors.push({ conversationId: turn.conversationId, reason }); + store.saveResult({ sourceId, conversationId: turn.conversationId, error: reason }); + } + emitProgress(scanOptions, { sourceId, phase: "add", current: memoryIdCount + deduped, total: store.count(sourceId), message: "Capturing conversation turns" }); + continue; + } let turnSucceeded = true; + const addAnalyticsBase = { + adapterId: `agent-source:${sourceId}`, + conversationId: turn.conversationId, + turnId: legacyTurnId(turn), + ...(scanMode ? { scanMode } : {}) + }; + const addStartedAt = Date.now(); // One turn is one memory. Splitting an agentic turn fans a single exchange // out into hundreds of near-empty tool-call fragments, so an oversized turn // is clipped to the wire budget instead of being fanned out. @@ -872,10 +917,18 @@ async function ingestPersistentSource( title: firstTurnLine(turn.messages) ?? `${sourceId} conversation`, tags: ["agent-source", sourceId], source: sourceId, - turnId: legacyTurnId(turn), + turnId: addAnalyticsBase.turnId, createdAt: turn.messages[0]!.createdAt, deferProcessing: true }); + if (!added.duplicate) { + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + options.memoryAddAnalytics?.trackAddSucceeded({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + storedCount: 1 + }); + } if (added.duplicate) deduped += turn.messages.length; else { memoryIdCount += 1; @@ -899,6 +952,12 @@ async function ingestPersistentSource( errorCount += 1; if (errors.length < 1000) errors.push({ conversationId: turn.conversationId, reason }); store.saveResult({ sourceId, conversationId: turn.conversationId, error: reason }); + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + options.memoryAddAnalytics?.trackAddFailed({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + error + }); } if (!turnSucceeded) activeConversationFailed = true; emitProgress(scanOptions, { sourceId, phase: "add", current: memoryIds.length + deduped, total: store.count(sourceId), message: "Adding raw memories" }); @@ -926,6 +985,10 @@ function readScanPage(store: AppAgentSourceScanStore, sourceId: string, cursor?: return page; } +function persistentScanMode(value: string | undefined): MemoryDesktopAddScanMode | undefined { + return value === "initial_subset" || value === "incremental" || value === "full" ? value : undefined; +} + function firstTurnLine(messages: readonly ConversationMessage[]): string | undefined { const value = messages.find((message) => message.role === "user")?.content; const line = value?.split(/\r?\n/).map((part) => part.trim()).find(Boolean); @@ -1294,7 +1357,8 @@ function conversationContentHash(messages: readonly ConversationMessage[]): stri content: message.content, createdAt: message.createdAt, toolName: conversationMetaString(message, "toolName") ?? conversationMetaString(message, "hermesToolName"), - toolCallId: conversationMetaString(message, "toolCallId") ?? conversationMetaString(message, "hermesToolCallId") + toolCallId: conversationMetaString(message, "toolCallId") ?? conversationMetaString(message, "hermesToolCallId"), + ...(message.sourceId === "codex" ? { sourceTurn: message.rawMeta } : {}) })); return createHash("sha256").update(JSON.stringify(content)).digest("hex"); } @@ -1381,6 +1445,14 @@ function buildConversationMemoryUnits(sourceId: string, messages: readonly Conve let current: ConversationMessage[] = []; for (const message of messages) { + if (sourceId === "codex") { + if (current.length > 0 && current[0]?.rawMeta.sourceTurnId !== message.rawMeta.sourceTurnId) { + pushCompleteMemoryUnit(sourceId, current, units); + current = []; + } + current.push(message); + continue; + } if (message.role === "user") { pushCompleteMemoryUnit(sourceId, current, units); current = [message]; @@ -1401,7 +1473,7 @@ function pushCompleteMemoryUnit( messages: readonly ConversationMessage[], units: SourceMemoryUnit[] ): void { - if (!isCompleteMemoryTurn(messages)) { + if (messages.length === 0 || (sourceId !== "codex" && !isCompleteMemoryTurn(messages))) { return; } const userMessage = messages[0]!; diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 89eb59e35..12505a5e3 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -145,15 +145,16 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba const session = accountSessionRepository.get(); return session.authenticated ? session.profile.userId : "local-user"; }; + const memoryAddAnalytics = createMemoryDesktopAddAnalytics({ + getUserId: resolveAnalyticsUserId, + getUserMode: resolveAnalyticsUserMode, + }); const ingestionService = options.ingestionService ?? createIngestionService({ memoryClient: options.memoryClient, agentSourceRepository: options.appStateStore.repositories.agentSources, - memoryAddAnalytics: createMemoryDesktopAddAnalytics({ - getUserId: resolveAnalyticsUserId, - getUserMode: resolveAnalyticsUserMode, - }), + memoryAddAnalytics, }); const agentSources = createAgentSourceService({ sourceRegistry, @@ -166,6 +167,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba getUserId: resolveAnalyticsUserId, getUserMode: resolveAnalyticsUserMode, }), + memoryAddAnalytics, scanStoreDirectory: join(dirname(options.appStateStore.databasePath), "agent-source-scans"), }); const toolConnectionAnalytics = createToolConnectionAnalytics({ diff --git a/App/backend/src/services/ingestion-service.ts b/App/backend/src/services/ingestion-service.ts index bc27f43fb..f97a3ac5e 100644 --- a/App/backend/src/services/ingestion-service.ts +++ b/App/backend/src/services/ingestion-service.ts @@ -1,5 +1,6 @@ /** Ingestion service module. */ import { createHash } from "node:crypto"; +import { orderedTurns, sourceTurnFromMessages, sourceTurnFailureReason, buildSourceTurnRequest } from "@memmy/agent-source-core"; import { setImmediate as yieldToEventLoop } from "node:timers/promises"; import type { ConversationMessage } from "../adapters/outbound/agent-source/types.js"; import type { MemoryClient } from "../adapters/outbound/memory-client/index.js"; @@ -64,7 +65,7 @@ export interface IngestionStats { /** Contract for create ingestion service options. */ export interface CreateIngestionServiceOptions { - memoryClient: Pick; + memoryClient: Pick; agentSourceRepository: Pick; memoryAddAnalytics?: Pick< MemoryDesktopAddAnalytics, @@ -163,6 +164,10 @@ async function processConversation( ctx: IngestionContext, stats: IngestionStats ): Promise { + if (ctx.sourceId === "codex") { + await processNativeConversation(options, messages, ctx, stats); + return; + } let processedTurns = 0; let incomplete = false; let failed = false; @@ -227,7 +232,6 @@ async function processConversation( turnId: request.turnId, ...(ctx.scanMode ? { scanMode: ctx.scanMode } : {}) }; - options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); const addStartedAt = Date.now(); try { @@ -239,12 +243,13 @@ async function processConversation( stats.written += turn.messages.length; stats.writtenMemories += 1; stats.memoryIds.push(added.id); + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); + options.memoryAddAnalytics?.trackAddSucceeded({ + ...addAnalyticsBase, + durationMs: Date.now() - addStartedAt, + storedCount: 1 + }); } - options.memoryAddAnalytics?.trackAddSucceeded({ - ...addAnalyticsBase, - durationMs: Date.now() - addStartedAt, - storedCount: added.duplicate ? 0 : 1 - }); for (const dedupKey of dedupKeys) { options.agentSourceRepository.markSeen(dedupKey, ctx.sourceId); @@ -258,6 +263,7 @@ async function processConversation( conversationId: turn.conversationId, reason: error instanceof Error ? error.message : "ingestion failed" }); + options.memoryAddAnalytics?.trackAddStarted(addAnalyticsBase); options.memoryAddAnalytics?.trackAddFailed({ ...addAnalyticsBase, durationMs: Date.now() - addStartedAt, @@ -274,6 +280,52 @@ async function processConversation( else stats.completedConversationIds.push(conversationId); } +async function processNativeConversation( + options: CreateIngestionServiceOptions, + messages: readonly ConversationMessage[], + ctx: IngestionContext, + stats: IngestionStats +): Promise { + let failed = false; + const values = (async function* () { yield* messages; })(); + for await (const turn of orderedTurns(values)) { + ctx.signal?.throwIfAborted(); + try { + const sourceTurn = sourceTurnFromMessages(turn.messages); + if (!sourceTurn) { + throw new Error(sourceTurnFailureReason(turn.messages)); + } + const result = await options.memoryClient.completeSourceTurn(buildSourceTurnRequest(sourceTurn, "agent_source_scan")); + if (result.status === "pending" || result.status === "conflict") { + throw new Error(result.reason ?? result.status); + } + if (result.status === "stored") { + const ids = result.result?.l1MemoryIds ?? []; + stats.written += turn.messages.length; + stats.writtenMemories += ids.length; + stats.memoryIds.push(...ids); + } else { + stats.deduped += turn.messages.length; + if (result.status === "existing") stats.dedupedMemories += 1; + } + for (const message of turn.messages) { + options.agentSourceRepository.markSeen(createDedupKey(ctx.sourceId, message.messageId), ctx.sourceId); + } + } catch (error) { + failed = true; + stats.failed += turn.messages.length; + stats.failedMemories += 1; + stats.errors.push({ conversationId: turn.conversationId, reason: error instanceof Error ? error.message : "native turn ingestion failed" }); + } + emitIngestionProgress(ctx, stats); + } + const conversationId = messages[0]?.conversationId; + if (conversationId) { + if (failed) stats.failedConversationIds.push(conversationId); + else stats.completedConversationIds.push(conversationId); + } +} + function emitIngestionProgress(ctx: IngestionContext, stats: IngestionStats): void { ctx.onProgress?.({ sourceId: ctx.sourceId, diff --git a/App/backend/src/services/panel-service.ts b/App/backend/src/services/panel-service.ts index 886845b38..03c825e5b 100644 --- a/App/backend/src/services/panel-service.ts +++ b/App/backend/src/services/panel-service.ts @@ -41,7 +41,10 @@ export function createPanelService(deps: { memoryClient: MemoryClient; getUserId }, async tasks(input, ctx) { - return deps.memoryClient.panelTasks(input, context(ctx)); + // The desktop task list shows the local library, like the Web viewer. + // Login must not hide tasks captured by hooks under a different userId. + // Keep service-token authentication, but omit the UI account filter. + return deps.memoryClient.panelTasks(input, { ...ctx, userId: undefined }); }, async deleteTask(id, ctx) { diff --git a/App/backend/src/services/tests/account-service.test.ts b/App/backend/src/services/tests/account-service.test.ts index 76d38026d..180105a4c 100644 --- a/App/backend/src/services/tests/account-service.test.ts +++ b/App/backend/src/services/tests/account-service.test.ts @@ -59,6 +59,84 @@ describe("AccountService", () => { expect(cloudCalls).toBe(0); }); + it("keeps social login unavailable in the phone-only China package", async () => { + let cloudCalls = 0; + const service = createAccountService({ + accountChannel: "phone", + cloudClient: { + ...createCloudClientStub(), + async startSocialLogin() { + cloudCalls += 1; + throw new Error("unexpected social-login start"); + }, + async getSocialLoginStatus() { + cloudCalls += 1; + throw new Error("unexpected social-login poll"); + } + }, + accountSessionRepository: createAccountSessionRepositoryStub() + }); + + await expect(service.startSocialLogin({ + provider: "google", + locale: "zh", + loginSource: "Memmy" + })).rejects.toMatchObject({ code: "invalid_argument" }); + await expect(service.getSocialLoginStatus({ + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001" + })).rejects.toMatchObject({ code: "invalid_argument" }); + expect(cloudCalls).toBe(0); + }); + + it("stores a completed international social login as an email-channel session", async () => { + const calls: unknown[] = []; + const service = createAccountService({ + accountChannel: "email", + cloudClient: { + ...createCloudClientStub(), + async getSocialLoginStatus(input) { + calls.push({ status: input }); + return { + status: "completed" as const, + result: { + uuid: "cloud.social.uuid", + accountUuid: "cloud-account-user-1", + isNewUser: false, + profile: cloudProfile(), + invitationResult: { status: "not_provided" as const } + } + }; + } + }, + accountSessionRepository: { + ...createAccountSessionRepositoryStub(), + upsert(input) { + calls.push({ upsert: input }); + return { + authenticated: true, + isNewUser: input.isNewUser ?? false, + profile: input.profile + }; + } + } + }); + + await expect(service.getSocialLoginStatus({ + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001" + })).resolves.toMatchObject({ + status: "completed", + result: { session: { authenticated: true, profile: { email: "hello@example.com" } } } + }); + expect(calls).toContainEqual({ + upsert: expect.objectContaining({ + cloudUuid: "cloud.social.uuid", + authChannel: "email" + }) + }); + }); + it("sends verification codes through cloud-client and rate-limits by channel address", async () => { const calls: string[] = []; let lastCodeSentAt: string | null = null; @@ -956,6 +1034,18 @@ function createCloudClientStub() { async login() { return { uuid: "cloud.login.uuid", accountUuid: "cloud-account-user-1", isNewUser: true, profile: cloudProfile() }; }, + async startSocialLogin() { + return { + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + expiresInSec: 600, + pollIntervalSec: 2 + }; + }, + async getSocialLoginStatus() { + return { status: "pending" as const }; + }, async logout() { return undefined; }, diff --git a/App/backend/src/services/tests/agent-runtime-services.test.ts b/App/backend/src/services/tests/agent-runtime-services.test.ts index b4f70e30f..d02299c05 100644 --- a/App/backend/src/services/tests/agent-runtime-services.test.ts +++ b/App/backend/src/services/tests/agent-runtime-services.test.ts @@ -82,14 +82,20 @@ describe("agent runtime services", () => { async panelItems(input, context) { contexts.push(context); return baseClient.panelItems(input, context); + }, + async deletePanelTask(id, context) { + contexts.push(context); + return { ok: true, id, deletedMemoryIds: [], serverTime: "2026-05-29T10:00:00.000Z" }; } }; const service = createPanelService({ memoryClient, getUserId: () => "account-user-1" }); await service.overview(runtimeCtx()); await service.items({ layer: "UserMemory" }, runtimeCtx()); + await service.deleteTask("episode-1", runtimeCtx()); expect(contexts).toEqual([ + expect.objectContaining({ adapterId: "cursor/main", userId: "account-user-1" }), expect.objectContaining({ adapterId: "cursor/main", userId: "account-user-1" }), expect.objectContaining({ adapterId: "cursor/main", userId: "account-user-1" }) ]); diff --git a/App/backend/src/services/tests/agent-source-service.test.ts b/App/backend/src/services/tests/agent-source-service.test.ts index e21419449..2b2d560f7 100644 --- a/App/backend/src/services/tests/agent-source-service.test.ts +++ b/App/backend/src/services/tests/agent-source-service.test.ts @@ -1,7 +1,6 @@ /** Agent source service tests. */ import { DatabaseSync } from "node:sqlite"; import { MANAGED_AGENT_DISCOVERY_PENDING_DATA_PATH } from "@memmy/local-api-contracts"; -import { legacyTurnId, legacyTurnRequestId } from "@memmy/agent-source-core"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -40,109 +39,6 @@ afterEach(() => { }); describe("agent source service", () => { - describe("persistent scan turn boundaries", () => { - it("stores one oversized tool turn once and reuses its legacy idempotency keys", async () => { - tempDir = mkdtempSync(join(tmpdir(), "memmy-persistent-one-turn-")); - const messages = createCompleteMemoryMessages("cursor", 1, "2026-05-28T10:00:00.000Z", { includeTool: true }) - .map((message) => message.role === "tool" ? { ...message, content: "Tool calls:\n\n- tool_1\n\n".repeat(30_000) } : message); - const turn = { sourceId: "cursor", conversationId: messages[0]!.conversationId, turnIndex: 0, messages }; - const memoryClient = createMockMemoryClient(); - const added: Parameters[0][] = []; - const service = createService({ - scanStoreDirectory: tempDir, - adapters: [createFakeAdapter("cursor", messages)], - memoryClient: { - ...memoryClient, - async addMemory(input) { - added.push(input); - return { ...await memoryClient.addMemory(input), id: "oversized-turn", duplicate: added.length > 1 }; - }, - async getMemoryProcessingStatus(ids) { - return { items: ids.map((memoryId) => ({ memoryId, state: "ready" as const, attemptCount: 0, manualRetryCount: 0, retryAction: "retry" as const, updatedAt: "2026-05-28T10:00:00.000Z" })), serverTime: "2026-05-28T10:00:00.000Z" }; - } - } - }); - - const first = await service.scanOne("cursor", { mode: "full" }); - expect(first.errors).toEqual([]); - expect(added).toHaveLength(1); - expect(added[0]).toMatchObject({ requestId: legacyTurnRequestId(turn), turnId: legacyTurnId(turn) }); - expect(added[0]?.content).toContain("truncated"); - const replay = await service.scanOne("cursor", { mode: "full" }); - expect(replay.errors).toEqual([]); - expect(added).toHaveLength(2); - expect(added[1]?.requestId).toBe(added[0]?.requestId); - expect(added[1]?.turnId).toBe(added[0]?.turnId); - expect(replay.skipped).toBe(messages.length); - expect(replay.memoryIdCount).toBe(0); - }); - - it.each([ - ["after the watermark", "2026-05-28T10:01:53.000Z", ["query 1"]], - ["ending exactly at the watermark", "2026-05-28T10:01:52.000Z", ["query 2", "query 1"]], - ["when the newest turn ends exactly at the watermark", "2026-05-28T10:02:02.000Z", ["query 1"]] - ] as const)("imports only complete turns %s from a changed long conversation", async (_label, since, expectedTitles) => { - tempDir = mkdtempSync(join(tmpdir(), "memmy-persistent-boundary-")); - const repository = createRepository(); - repository.upsertSource({ sourceId: "cursor", displayName: "Cursor", dataPath: "/tmp/cursor", builtin: true }); - repository.upsertScanWatermark({ sourceId: "cursor", mode: "incremental", baselineAt: since, latestSeenCreatedAt: since, updatedAt: since }); - const memoryClient = createMockMemoryClient(); - const added: Parameters[0][] = []; - const service = createService({ - repository, - scanStoreDirectory: tempDir, - adapters: [createFakeAdapter("cursor", createCompleteMemoryMessages("cursor", 3, "2026-05-28T10:02:00.000Z") - .map((message) => ({ ...message, conversationId: "long-conversation" })))], - memoryClient: { - ...memoryClient, - async addMemory(input) { added.push(input); return memoryClient.addMemory(input); }, - async getMemoryProcessingStatus(ids) { - return { items: ids.map((memoryId) => ({ memoryId, state: "ready" as const, attemptCount: 0, manualRetryCount: 0, retryAction: "retry" as const, updatedAt: since })), serverTime: since }; - } - } - }); - - const result = await service.scanOne("cursor", { mode: "incremental" }); - - expect(result.errors).toEqual([]); - expect(added.map((input) => input.title)).toEqual(expectedTitles); - for (const input of added) expect(input.content).toContain(String(input.title).replace("query", "answer")); - expect(repository.getScanWatermark("cursor")?.latestSeenCreatedAt).toBe("2026-05-28T10:02:02.000Z"); - }); - - it.each(["full", "initial_subset"] as const)("preserves the %s history selection with an existing watermark", async (mode) => { - tempDir = mkdtempSync(join(tmpdir(), "memmy-persistent-mode-")); - const repository = createRepository(); - const boundary = "2026-05-28T10:02:02.000Z"; - repository.upsertSource({ sourceId: "cursor", displayName: "Cursor", dataPath: "/tmp/cursor", builtin: true }); - repository.upsertScanWatermark({ sourceId: "cursor", mode: "incremental", baselineAt: boundary, latestSeenCreatedAt: boundary, updatedAt: boundary }); - const added: Parameters[0][] = []; - const memoryClient = createMockMemoryClient(); - const count = mode === "initial_subset" ? 1001 : 3; - const service = createService({ - repository, - scanStoreDirectory: tempDir, - adapters: [createFakeAdapter("cursor", createCompleteMemoryMessages("cursor", count, "2026-05-28T10:02:00.000Z") - .map((message) => ({ ...message, conversationId: "long-conversation" })))], - memoryClient: { - ...memoryClient, - async addMemory(input) { added.push(input); return memoryClient.addMemory(input); }, - async getMemoryProcessingStatus(ids) { - return { items: ids.map((memoryId) => ({ memoryId, state: "ready" as const, attemptCount: 0, manualRetryCount: 0, retryAction: "retry" as const, updatedAt: boundary })), serverTime: boundary }; - } - } - }); - - const result = await service.scanOne("cursor", { mode }); - - expect(result.errors).toEqual([]); - expect(added).toHaveLength(mode === "initial_subset" ? 1000 : 3); - expect(added.map((input) => input.title)).toContain("query 1"); - expect(added.map((input) => input.title)).toContain(`query ${mode === "initial_subset" ? 1000 : 3}`); - if (mode === "initial_subset") expect(added.map((input) => input.title)).not.toContain("query 1001"); - }); - }); - it("lists builtin registry sources together with persisted manual sources", async () => { const repository = createRepository(); repository.upsertSource({ @@ -1592,6 +1488,102 @@ describe("agent source service", () => { }, }); }); + + it("emits memory_desktop add analytics for persistent scan writes", async () => { + const events: Array<{ name: string; payload: Record }> = []; + tempDir = mkdtempSync(join(tmpdir(), "persistent-scan-analytics-")); + const service = createService({ + adapters: [createFakeAdapter("cursor", createCompleteMemoryMessages("cursor", 1, "2026-05-28T10:00:00.000Z"))], + scanStoreDirectory: join(tempDir, "scans"), + memoryClient: createReadyMemoryClient(), + memoryAddAnalytics: createAddAnalyticsRecorder(events) + }); + + const result = await service.scanOne("cursor", { scanJobId: "job-add-analytics", mode: "initial_subset" }); + + expect(result.errors).toEqual([]); + expect(result.memoryIdCount).toBe(1); + expect(events.map((event) => event.name)).toEqual(["started", "succeeded"]); + expect(events[0]?.payload).toMatchObject({ + adapterId: "agent-source:cursor", + conversationId: "cursor-conv-1", + scanMode: "initial_subset" + }); + expect(events[1]?.payload).toMatchObject({ + adapterId: "agent-source:cursor", + conversationId: "cursor-conv-1", + scanMode: "initial_subset", + storedCount: 1 + }); + expect(typeof events[0]?.payload.turnId).toBe("string"); + expect(typeof events[1]?.payload.durationMs).toBe("number"); + }); + + it("emits add_failed analytics when a persistent scan write throws", async () => { + const events: Array<{ name: string; payload: Record }> = []; + tempDir = mkdtempSync(join(tmpdir(), "persistent-scan-add-failed-")); + const service = createService({ + adapters: [createFakeAdapter("cursor", createCompleteMemoryMessages("cursor", 1, "2026-05-28T10:00:00.000Z"))], + scanStoreDirectory: join(tempDir, "scans"), + memoryClient: { + ...createReadyMemoryClient(), + async addMemory() { + throw new Error("write failed"); + } + }, + memoryAddAnalytics: createAddAnalyticsRecorder(events) + }); + + const result = await service.scanOne("cursor", { scanJobId: "job-add-failed", mode: "incremental" }); + + expect(result.errors[0]?.reason).toBe("write failed"); + expect(events.map((event) => event.name)).toEqual(["started", "failed"]); + expect(events[1]?.payload).toMatchObject({ + adapterId: "agent-source:cursor", + conversationId: "cursor-conv-1", + scanMode: "incremental" + }); + expect(events[1]?.payload.error).toBeInstanceOf(Error); + }); + + it("does not emit add analytics when a persistent scan write is a duplicate", async () => { + const events: Array<{ name: string; payload: Record }> = []; + tempDir = mkdtempSync(join(tmpdir(), "persistent-scan-dup-analytics-")); + const service = createService({ + adapters: [createFakeAdapter("cursor", createCompleteMemoryMessages("cursor", 1, "2026-05-28T10:00:00.000Z"))], + scanStoreDirectory: join(tempDir, "scans"), + memoryClient: { + ...createReadyMemoryClient(), + async addMemory() { + return { id: "memory-dup", duplicate: true }; + } + }, + memoryAddAnalytics: createAddAnalyticsRecorder(events) + }); + + const result = await service.scanOne("cursor", { scanJobId: "job-add-dup", mode: "full" }); + + expect(result.memoryIdCount).toBe(0); + expect(events).toEqual([]); + }); + + it("does not emit add analytics for already checkpointed persistent scan turns", async () => { + const events: Array<{ name: string; payload: Record }> = []; + tempDir = mkdtempSync(join(tmpdir(), "persistent-scan-skip-analytics-")); + const service = createService({ + adapters: [createFakeAdapter("cursor", createCompleteMemoryMessages("cursor", 1, "2026-05-28T10:00:00.000Z"))], + scanStoreDirectory: join(tempDir, "scans"), + memoryClient: createReadyMemoryClient(), + memoryAddAnalytics: createAddAnalyticsRecorder(events) + }); + + await service.scanOne("cursor", { scanJobId: "job-skip-first", mode: "incremental" }); + expect(events.map((event) => event.name)).toEqual(["started", "succeeded"]); + events.length = 0; + await service.scanOne("cursor", { scanJobId: "job-skip-second", mode: "incremental" }); + + expect(events).toEqual([]); + }); }); function createService( @@ -1602,8 +1594,13 @@ function createService( skillDistributionService?: SkillDistributionService; memoryClient?: MemoryClient; agentSourceAnalytics?: AgentSourceLifecycleAnalytics; - getScanPermission?: () => Promise; + memoryAddAnalytics?: { + trackAddStarted: (input: Record) => void; + trackAddSucceeded: (input: Record) => void; + trackAddFailed: (input: Record) => void; + }; scanStoreDirectory?: string; + getScanPermission?: () => Promise; } = {} ): AgentSourceService { return createAgentSourceService({ @@ -1612,8 +1609,9 @@ function createService( ingestionService: options.ingestionService ?? createFakeIngestionService(), memoryClient: options.memoryClient ?? createMockMemoryClient(), agentSourceAnalytics: options.agentSourceAnalytics, - getScanPermission: options.getScanPermission, + memoryAddAnalytics: options.memoryAddAnalytics as never, scanStoreDirectory: options.scanStoreDirectory, + getScanPermission: options.getScanPermission, skillDistributionService: options.skillDistributionService ?? ({ @@ -1717,6 +1715,52 @@ function createFakeAdapter( }; } +function createAddAnalyticsRecorder(events: Array<{ name: string; payload: Record }>) { + return { + trackAddStarted(input: Record) { + events.push({ name: "started", payload: { ...input } }); + }, + trackAddSucceeded(input: Record) { + events.push({ name: "succeeded", payload: { ...input } }); + }, + trackAddFailed(input: Record) { + events.push({ name: "failed", payload: { ...input } }); + } + }; +} + +function createReadyMemoryClient(): MemoryClient { + const base = createMockMemoryClient(); + return { + ...base, + async enqueueImportSummaries(memoryIds) { + return { + enqueued: memoryIds?.length ?? 0, + memoryIds: memoryIds ?? [], + serverTime: "2026-05-28T10:00:00.000Z" + }; + }, + async getMemoryProcessingStatus(memoryIds) { + return { + items: memoryIds.map((memoryId) => ({ + memoryId, + state: "ready" as const, + stage: null, + activeJobId: null, + attemptCount: 1, + manualRetryCount: 0, + retryAction: "retry" as const, + errorCode: null, + errorMessage: null, + failedAt: null, + updatedAt: "2026-05-28T10:00:00.000Z" + })), + serverTime: "2026-05-28T10:00:00.000Z" + }; + } + }; +} + function createFakeIngestionService(): IngestionService { return { async ingest(messages) { diff --git a/App/backend/src/services/tests/agent-source-wire-budget.test.ts b/App/backend/src/services/tests/agent-source-wire-budget.test.ts deleted file mode 100644 index 6e1aa5981..000000000 --- a/App/backend/src/services/tests/agent-source-wire-budget.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Server } from "node:http"; -import { afterEach, describe, expect, it } from "vitest"; -import { legacyTurnId, legacyTurnRequestId, renderTurnClipped, type ConversationMessage } from "@memmy/agent-source-core"; -import { createHttpMemoryClient } from "../../adapters/outbound/memory-client/http-memory-client.js"; -import { createMemoryHttpServer, closeMemoryHttpServer } from "../../../../../Memory/src/server/http.js"; -import type { MemoryService } from "../../../../../Memory/src/service/memory-service.js"; -import type { AgentSourceExecutor } from "../../../../../Memory/src/agent-source/runtime.js"; - -const servers: Server[] = []; -afterEach(async () => { - await Promise.all(servers.splice(0).map((server) => closeMemoryHttpServer(server))); -}); - -describe("scanned turn HTTP request budget", () => { - it.each([ - ["quoted JSON tool output", '{"value":"quoted \\ path"}\n'.repeat(30_000)], - ["JSON-escaped control characters", "\u0001".repeat(400_000)], - ])("stores %s as one memory through the actual Memory HTTP body limit", async (_label, toolContent) => { - const stored: Array<{ content: string }> = []; - const server = createMemoryHttpServer({ - service: { - idempotent(_operation: unknown, _input: unknown, _audit: unknown, run: () => unknown) { return run(); }, - addMemory(input: { content: string }) { - stored.push(input); - return { id: "wire-fixture", kind: "trace", memoryLayer: "L1", status: "activated", title: "one turn", summary: "stored", tags: [], createdAt: "2026-09-08T00:00:00.000Z", serverTime: "2026-09-08T00:00:00.000Z" }; - }, - } as unknown as MemoryService, - auth: { localServiceToken: "fixture-token" }, - agentSourceExecutor: { dispose() {} } as unknown as AgentSourceExecutor, - pluginRuntimeAnalytics: { track() {}, async trackAwait() {}, async flush() {} }, - workerStartupFallbackMs: 60_000, - }); - servers.push(server); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("expected TCP address"); - const bodies: string[] = []; - const client = createHttpMemoryClient({ baseUrl: `http://127.0.0.1:${address.port}`, token: "fixture-token", timeoutMs: 5_000, maxRetries: 0 }, { - fetchImpl: async (url, init) => { bodies.push(String(init?.body)); return fetch(url, init); }, - }); - const message = (messageId: string, role: ConversationMessage["role"], content: string): ConversationMessage => ({ - messageId, sourceId: "fixture", conversationId: "conversation", role, content, - createdAt: "2026-09-08T00:00:00.000Z", workspacePath: null, gitRoot: null, rawMeta: {}, - }); - const turn = { sourceId: "fixture", conversationId: "conversation", turnIndex: 0, messages: [ - message("u", "user", "one turn"), message("t", "tool", toolContent), message("a", "assistant", "finished"), - ] }; - const content = renderTurnClipped(turn.messages); - - await expect(client.addMemory({ - requestId: legacyTurnRequestId(turn), adapterId: "agent-source:fixture", content, - layer: "L1", title: "one turn", tags: ["agent-source", "fixture"], source: "fixture", - turnId: legacyTurnId(turn), createdAt: turn.messages[0]!.createdAt, deferProcessing: true, - })).resolves.toMatchObject({ id: "wire-fixture" }); - - expect(bodies).toHaveLength(1); - expect(Buffer.byteLength(bodies[0]!)).toBeLessThanOrEqual(2 * 1024 * 1024); - expect(stored).toHaveLength(1); - expect(stored[0]?.content).toBe(content); - }); -}); diff --git a/App/backend/src/services/tests/ingestion-service.test.ts b/App/backend/src/services/tests/ingestion-service.test.ts index 3a6d9b62f..05244185f 100644 --- a/App/backend/src/services/tests/ingestion-service.test.ts +++ b/App/backend/src/services/tests/ingestion-service.test.ts @@ -12,6 +12,74 @@ import { import type { AgentSourceRepository } from "../../infrastructure/agent-source-store/index.js"; import type { ConversationMessage } from "../../adapters/outbound/agent-source/types.js"; +describe("native Codex ingestion", () => { + function nativeMessages(complete = true): ConversationMessage[] { + const sourceTurn = { + source: "codex", conversationId: "native-conversation", turnId: "native-turn", sequence: 1, + startedAt: "2026-09-09T10:00:00.000Z", completedAt: "2026-09-09T10:01:00.000Z", + completionEvidence: "task_complete:native-turn", query: "Run tests", answer: "Tests passed", status: "succeeded", + toolCalls: [{ id: "test-call", name: "test", input: "npm test", output: "passed", success: true }], + toolResults: [{ id: "test-call", output: "passed", success: true }] + }; + return ["user", "assistant"].map((role, index) => ({ + messageId: `native-${index}`, sourceId: "codex", conversationId: "native-conversation", + role: role as "user" | "assistant", content: index === 0 ? sourceTurn.query : sourceTurn.answer, + createdAt: index === 0 ? sourceTurn.startedAt : sourceTurn.completedAt, + workspacePath: null, gitRoot: null, + rawMeta: { sourceTurnId: "native-turn", sourceTurnState: complete ? "complete" : "turn_incomplete", + sourceTurnReason: complete ? undefined : "turn_incomplete", ...(complete && index === 1 ? { sourceTurn } : {}) } + })); + } + + it("submits the structured native turn once and never creates an import-summary memory", async () => { + const addMemory = vi.fn(); + const completeSourceTurn = vi.fn().mockResolvedValue({ status: "stored", result: { l1MemoryIds: ["l1-native"] } }); + const markSeen = vi.fn(); + const stats = await createService({ addMemory, completeSourceTurn }, { markSeen }).ingest(toAsyncIterable(nativeMessages()), { sourceId: "codex" }); + expect(addMemory).not.toHaveBeenCalled(); + expect(completeSourceTurn).toHaveBeenCalledOnce(); + expect(completeSourceTurn).toHaveBeenCalledWith(expect.objectContaining({ + channel: "agent_source_scan", sourceTurn: expect.objectContaining({ conversationId: "native-conversation", turnId: "native-turn" }), + toolCalls: [expect.objectContaining({ id: "test-call", input: "npm test", output: "passed" })] + })); + expect(stats.memoryIds).toEqual(["l1-native"]); + expect(markSeen).toHaveBeenCalledTimes(2); + }); + + it.each(["pending", "conflict"])("retains %s turns for retry without marking seen or completing the conversation", async status => { + const markSeen = vi.fn(); + const addMemory = vi.fn(); + const completeSourceTurn = vi.fn().mockResolvedValue({ status, reason: "episode_unresolved" }); + const stats = await createService({ addMemory, completeSourceTurn }, { markSeen }).ingest(toAsyncIterable(nativeMessages()), { sourceId: "codex" }); + expect(markSeen).not.toHaveBeenCalled(); expect(addMemory).not.toHaveBeenCalled(); + expect(stats.completedConversationIds).toEqual([]); + expect(stats.failedConversationIds).toEqual(["native-conversation"]); + expect(stats.errors[0]?.reason).toContain("episode_unresolved"); + }); + + it("does not submit incomplete evidence, and retries after completion arrives on the same message IDs", async () => { + const completeSourceTurn = vi.fn().mockResolvedValue({ status: "existing", result: { l1MemoryIds: ["same-l1"] } }); + const addMemory = vi.fn(); const markSeen = vi.fn(); + const service = createService({ addMemory, completeSourceTurn }, { markSeen }); + const pending = await service.ingest(toAsyncIterable(nativeMessages(false)), { sourceId: "codex" }); + expect(completeSourceTurn).not.toHaveBeenCalled(); expect(markSeen).not.toHaveBeenCalled(); + expect(pending.errors[0]?.reason).toContain("turn_incomplete"); + const recovered = await service.ingest(toAsyncIterable(nativeMessages()), { sourceId: "codex" }); + expect(completeSourceTurn).toHaveBeenCalledOnce(); expect(markSeen).toHaveBeenCalledTimes(2); + expect(recovered.dedupedMemories).toBe(1); expect(recovered.writtenMemories).toBe(0); + expect(addMemory).not.toHaveBeenCalled(); + }); + + it("acknowledges rejected old turns without reporting a newly written memory", async () => { + const markSeen = vi.fn(); + const completeSourceTurn = vi.fn().mockResolvedValue({ status: "rejected", reason: "legacy_before_activation" }); + const stats = await createService({ completeSourceTurn }, { markSeen }).ingest(toAsyncIterable(nativeMessages()), { sourceId: "codex" }); + expect(stats.writtenMemories).toBe(0); expect(stats.memoryIds).toEqual([]); + expect(stats.completedConversationIds).toEqual(["native-conversation"]); + expect(markSeen).toHaveBeenCalledTimes(2); + }); +}); + describe("ingestion service", () => { it("imports each contiguous conversation as turn memories through memory add", async () => { const added: Array> = []; @@ -480,7 +548,7 @@ describe("ingestion service", () => { const stats = await service.ingest( toAsyncIterable([createMessage("conv-a", 1), createMessage("conv-a", 2)]), - { sourceId: "codex" } + { sourceId: "cursor" } ); expect(markSeen).toHaveBeenCalledTimes(2); @@ -491,9 +559,7 @@ describe("ingestion service", () => { dedupedMemories: 1, memoryIds: [] }); - expect(succeeded).toEqual([ - expect.objectContaining({ storedCount: 0 }) - ]); + expect(succeeded).toEqual([]); }); it("does not import user-only or assistant-only turns as memories", async () => { diff --git a/App/backend/src/services/tests/native-persistent-scan.test.ts b/App/backend/src/services/tests/native-persistent-scan.test.ts new file mode 100644 index 000000000..7e5140694 --- /dev/null +++ b/App/backend/src/services/tests/native-persistent-scan.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readCodexRollout } from "@memmy/agent-source-core"; +import { createAgentSourceService } from "../agent-source-service.js"; +import { createIngestionService } from "../ingestion-service.js"; +import { createSourceRegistry } from "../../adapters/outbound/agent-source/source-registry.js"; +import { createCodexSourceAdapter } from "../../adapters/outbound/agent-source/codex/index.js"; +import { createAppStateStore, type AppStateStore } from "../../infrastructure/app-state-store/index.js"; +import { createMockMemoryClient } from "../../tests/support/mock-memory-client.js"; + +const roots: string[] = []; const stores: AppStateStore[] = []; +afterEach(() => { stores.splice(0).forEach(store => store.close()); roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true })); }); + +describe("persistent Codex scan", () => { + it("does not checkpoint a lost response; retries the same source turn without import splitting", async () => { + const root = mkdtempSync(join(tmpdir(), "native-backend-scan-")); roots.push(root); + const store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); stores.push(store); + const repository = store.repositories.agentSources; + const file = join(root, "rollout.jsonl"); + const at = "2099-09-09T10:00:00.000Z"; + const event = (type: string, payload: Record) => ({ type, timestamp: at, payload }); + writeFileSync(file, [event("session_meta", { id: "source-session" }), event("event_msg", { type: "task_started", turn_id: "turn-1" }), + event("response_item", { type: "message", role: "user", content: [{ text: "Run tests" }] }), + event("response_item", { type: "custom_tool_call", call_id: "call-1", name: "test", input: "npm test" }), + event("response_item", { type: "custom_tool_call_output", call_id: "call-1", output: "passed ".repeat(4000) }), + event("response_item", { type: "message", role: "assistant", content: [{ text: "Tests passed" }] }), + event("event_msg", { type: "task_complete", turn_id: "turn-1" })].map(value => JSON.stringify(value)).join("\n") + "\n"); + const client = createMockMemoryClient(); + const addMemory = vi.spyOn(client, "addMemory"); const enqueue = vi.spyOn(client, "enqueueImportSummaries"); + const complete = vi.spyOn(client, "completeSourceTurn").mockRejectedValueOnce(new Error("response lost")).mockResolvedValue({ status: "existing", result: { + turnId: "turn-1", sessionId: "session", episodeId: "episode", rawTurnId: "raw", l1MemoryId: "same-l1", l1MemoryIds: ["same-l1"], closedEpisodeIds: [], scheduledEvolution: false, jobs: [], serverTime: at + } }); + const service = createAgentSourceService({ sourceRegistry: createSourceRegistry([{ descriptor: { sourceId: "codex", displayName: "Codex", builtin: true, dataPath: root }, + detect: async () => true, async *scan() { for await (const message of readCodexRollout(file)) yield { ...message, sourceId: "codex", workspacePath: null, gitRoot: null }; } }]), + memoryClient: client, agentSourceRepository: repository, ingestionService: createIngestionService({ memoryClient: client, agentSourceRepository: repository }), + skillDistributionService: { install: async () => undefined, uninstall: async () => undefined, installPlugin: async () => undefined, uninstallPlugin: async () => undefined }, + scanStoreDirectory: join(root, "scans") }); + const failed = await service.scanOne("codex", { scanJobId: "same-job", mode: "incremental" }); + expect(failed.errors[0]?.reason).toBe("response lost"); + expect(repository.getConversationCheckpoint("codex", "source-session")).toBeNull(); + expect(repository.getScanWatermark("codex")).toBeNull(); + const retried = await service.scanOne("codex", { scanJobId: "same-job", mode: "incremental" }); + expect(retried.errors).toEqual([]); expect(retried.memoryIdCount).toBe(0); + expect(complete).toHaveBeenCalledTimes(2); + expect(complete.mock.calls[0]?.[0]).toEqual(complete.mock.calls[1]?.[0]); + expect(complete.mock.calls[0]?.[0].toolCalls).toEqual([expect.objectContaining({ id: "call-1", input: "npm test", output: "passed ".repeat(4000) })]); + expect(repository.getConversationCheckpoint("codex", "source-session")).not.toBeNull(); + expect(addMemory).not.toHaveBeenCalled(); expect(enqueue).not.toHaveBeenCalled(); + }); +}); + + +describe("nonpersistent Codex scan window", () => { + it.each([undefined, 2])("keeps a complete new turn across since without reselecting old conversations (maxMessages=%s)", async maxMessages => { + const root = mkdtempSync(join(tmpdir(), "native-backend-window-")); roots.push(root); + const store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); stores.push(store); + const repository = store.repositories.agentSources; + const writeTurn = (file: string, conversationId: string, start: string, end: string) => { + const event = (timestamp: string, type: string, payload: Record) => ({ timestamp, type, payload }); + writeFileSync(join(root, file), [ + event(start, "session_meta", { id: conversationId }), + event(start, "event_msg", { type: "task_started", turn_id: `${conversationId}-turn` }), + event(start, "response_item", { type: "message", role: "user", content: [{ text: "Inspect the complete source before changing it." }] }), + event(start, "response_item", { type: "function_call", call_id: "read-call", name: "read", arguments: { path: "source.ts" } }), + event(end, "response_item", { type: "function_call_output", call_id: "read-call", output: "Complete source contents" }), + event(end, "response_item", { type: "message", role: "assistant", content: [{ text: "I inspected the complete source and fixed it." }] }), + event(end, "event_msg", { type: "task_complete", turn_id: `${conversationId}-turn` }) + ].map(record => JSON.stringify(record)).join("\n") + "\n"); + }; + writeTurn("rollout-a-old.jsonl", "old-conversation", "2099-09-09T09:00:00.000Z", "2099-09-09T09:01:00.000Z"); + writeTurn("rollout-b-new.jsonl", "new-conversation", "2099-09-09T10:01:00.000Z", "2099-09-09T10:03:00.000Z"); + const client = createMockMemoryClient(); + const complete = vi.spyOn(client, "completeSourceTurn"); + const add = vi.spyOn(client, "addMemory"); + const service = createAgentSourceService({ + sourceRegistry: createSourceRegistry([createCodexSourceAdapter({ sessionsRoot: root })]), + memoryClient: client, agentSourceRepository: repository, + ingestionService: createIngestionService({ memoryClient: client, agentSourceRepository: repository }), + skillDistributionService: { install: async () => undefined, uninstall: async () => undefined, installPlugin: async () => undefined, uninstallPlugin: async () => undefined } + }); + const options = { mode: "incremental" as const, since: "2099-09-09T10:02:00.000Z", maxMessages }; + const collected = await service.collectOne("codex", options); + expect(collected.conversationIds).toEqual(["new-conversation"]); + expect(collected.messages).toHaveLength(5); + expect(collected.messages[0]?.content).toBe("Inspect the complete source before changing it."); + expect(collected.messages.at(-1)).toMatchObject({ role: "system", content: "Codex task_complete" }); + const result = await service.scanOne("codex", options); + expect(result.errors).toEqual([]); + expect(complete).toHaveBeenCalledOnce(); + expect(complete).toHaveBeenCalledWith(expect.objectContaining({ + sourceTurn: expect.objectContaining({ conversationId: "new-conversation", turnId: "new-conversation-turn", startedAt: "2099-09-09T10:01:00.000Z", completedAt: "2099-09-09T10:03:00.000Z" }), + query: "Inspect the complete source before changing it.", answer: "I inspected the complete source and fixed it.", + toolCalls: [{ id: "read-call", name: "read", input: { path: "source.ts" }, output: "Complete source contents" }], + toolResults: [{ id: "read-call", output: "Complete source contents" }] + })); + expect(add).not.toHaveBeenCalled(); + expect(repository.getConversationCheckpoint("codex", "old-conversation")).toBeNull(); + }); +}); diff --git a/App/backend/src/services/tests/panel-task-identity.integration.test.ts b/App/backend/src/services/tests/panel-task-identity.integration.test.ts new file mode 100644 index 000000000..d4ff9429a --- /dev/null +++ b/App/backend/src/services/tests/panel-task-identity.integration.test.ts @@ -0,0 +1,120 @@ +import type { Server } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { createMemoryHttpServer } from "../../../../../Memory/src/index.js"; +import { createMemoryServiceFixture } from "../../../../../Memory/tests/fixtures/memory-service-fixture.js"; +import { createHttpMemoryClient } from "../../adapters/outbound/memory-client/http-memory-client.js"; +import { createPanelService } from "../panel-service.js"; + +const { cleanup, createTestService } = createMemoryServiceFixture(); +let server: Server | undefined; + +afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => server!.close(error => error ? reject(error) : resolve())); + server = undefined; + } + cleanup(); +}); + +async function fixture() { + const { service } = createTestService(); + const namespace = (userId: string) => ({ source: "cursor", profileId: "default", userId }); + server = createMemoryHttpServer({ + service, + startAgentSourceAutomation: false, + auth: { + localServiceToken: "local-panel-token", + scopedApiKeys: { + "scoped-panel-token": { namespace: namespace("account-user"), scopes: ["panel:read"] } + }, + cloudAccessTokens: { "cloud-panel-token": namespace("account-user") } + } + }); + await new Promise(resolve => server!.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected TCP address"); + const endpoint = `http://127.0.0.1:${address.port}`; + return { + service, + endpoint, + addTask(userId: string, label: string) { + const session = service.openSession({ namespace: namespace(userId) }); + return service.completeTurn(`turn-${label}`, { + sessionId: session.sessionId, + query: `issue 409 ${label}`, + answer: `Completed ${label}` + }).episodeId; + }, + panel(token: string, getUserId: () => string) { + return createPanelService({ + memoryClient: createHttpMemoryClient({ baseUrl: endpoint, token, timeoutMs: 5000, maxRetries: 0 }), + getUserId + }); + }, + async viewer(page: number) { + const response = await fetch(`${endpoint}/api/v1/episodes?page=${page}`, { headers: { "x-memmy-viewer": "1" } }); + expect(response.status).toBe(200); + return response.json() as Promise<{ tasks: Array<{ id: string }>; total: number }>; + } + }; +} + +// A caller's account identity must not become a filter for the machine's task dashboard. +const ctx = { adapterId: "runtime", userId: "stale-caller-user", timeZone: "+08:00" }; + +describe("desktop task identity / issue 409", () => { + it("keeps local and account tasks visible across login, account switching and logout, matching the viewer", async () => { + const f = await fixture(); + const localIds = Array.from({ length: 21 }, (_, i) => f.addTask("local-user", `local-task-${i}`)); + const accountId = f.addTask("account-user", "account-task"); + const previousAccountId = f.addTask("previous-account", "previous-task"); + const expectedIds = [...localIds, accountId, previousAccountId].sort(); + let currentUser = "local-user"; + const panel = f.panel("local-panel-token", () => currentUser); + + for (const userId of ["local-user", "account-user", "another-account", "local-user"]) { + currentUser = userId; + const first = await panel.tasks({ page: 1 }, ctx); + const second = await panel.tasks({ page: 2 }, ctx); + expect(first).toMatchObject({ total: 23, totalPages: 2, pageSize: 20, page: 1, hasNext: true, hasPrev: false }); + expect(second).toMatchObject({ total: 23, page: 2, hasNext: false, hasPrev: true }); + expect(first.tasks).toHaveLength(20); + expect(second.tasks).toHaveLength(3); + expect([...first.tasks, ...second.tasks].map(task => task.id).sort()).toEqual(expectedIds); + for (const [page, desktop] of [[1, first], [2, second]] as const) { + const viewer = await f.viewer(page); + expect(desktop.tasks.map(task => task.id)).toEqual(viewer.tasks.map(task => task.id)); + expect(desktop.total).toBe(viewer.total); + } + const searched = await panel.tasks({ q: "local-task-20", page: 99 }, ctx); + expect(searched).toMatchObject({ total: 1, page: 1, totalPages: 1 }); + expect(searched.tasks.map(task => task.id)).toEqual([localIds[20]]); + expect(searched.tasks[0]?.turns).toEqual(expect.arrayContaining([ + expect.objectContaining({ userText: "issue 409 local-task-20", assistantText: "Completed local-task-20" }) + ])); + expect(await panel.tasks({ q: "no-matching-task" }, ctx)).toMatchObject({ total: 0, tasks: [] }); + } + + // Displaying local data must not migrate the original task ownership. + for (const [userId, total] of [["local-user", 21], ["account-user", 1], ["previous-account", 1]] as const) { + expect(f.service.panelTasks({ namespace: { userId } }).total).toBe(total); + } + }); + + it("preserves Memory service authentication and token-bound user scopes", async () => { + const f = await fixture(); + const localId = f.addTask("local-user", "local-scope-task"); + const accountId = f.addTask("account-user", "account-scope-task"); + for (const token of ["scoped-panel-token", "cloud-panel-token"]) { + const panel = f.panel(token, () => "account-user"); + const result = await panel.tasks({}, ctx); + expect(result.total).toBe(1); + expect(result.tasks.map(task => task.id)).toEqual([accountId]); + if (token === "scoped-panel-token") { + await expect(panel.deleteTask(localId, ctx)).rejects.toMatchObject({ code: "forbidden" }); + } + } + await expect(f.panel("invalid-token", () => "account-user").tasks({}, ctx)) + .rejects.toMatchObject({ code: "unauthorized" }); + }); +}); diff --git a/App/backend/src/tests/load-env.test.ts b/App/backend/src/tests/load-env.test.ts index ae5a74f05..7e2322a2f 100644 --- a/App/backend/src/tests/load-env.test.ts +++ b/App/backend/src/tests/load-env.test.ts @@ -43,13 +43,15 @@ describe("backend cloud-service env loading", () => { expect(env.MEMMY_CLOUD_SERVICE).toBe("https://dev.example.test"); }); - it("fails closed when a requested packaged manifest is missing or malformed", () => { + it("fails closed when a requested packaged manifest is missing or uses a non-HTTPS origin", () => { const root = fixtureRoot(); expect(() => loadCloudServiceEnv({ env: {}, manifestPath: join(root, "missing.json") })) .toThrow(/manifest is missing/); - const manifestPath = join(root, "desktop-edition.json"); - writeFileSync(manifestPath, JSON.stringify({ cloudService: "http://unsafe.example.test" })); - expect(() => loadCloudServiceEnv({ env: {}, manifestPath })).toThrow(/HTTPS/); + for (const cloudService of ["http://unsafe.example.test", "ftp://unsafe.example.test"]) { + const manifestPath = join(root, `${cloudService.slice(0, 3)}-desktop-edition.json`); + writeFileSync(manifestPath, JSON.stringify({ cloudService })); + expect(() => loadCloudServiceEnv({ env: {}, manifestPath })).toThrow(/HTTPS/); + } }); }); diff --git a/App/backend/src/tests/local-app-contracts.test.ts b/App/backend/src/tests/local-app-contracts.test.ts index 39734b791..050dc5584 100644 --- a/App/backend/src/tests/local-app-contracts.test.ts +++ b/App/backend/src/tests/local-app-contracts.test.ts @@ -28,6 +28,8 @@ import { ModelConfigTestResultSchema, ModelConfigViewSchema, MODEL_NAME_MAX_LENGTH, + ModelProviderSchema, + canonicalCatalogProviderId, PatchAppSettingsInputSchema, PatchOnboardingInputSchema, PatchPrivacyInputSchema, @@ -44,6 +46,28 @@ import { } from "@memmy/local-api-contracts"; describe("local app contracts", () => { + it("accepts StepFun and Xiaomi as text providers and canonicalises their catalog ids", () => { + expect(ModelProviderSchema.safeParse("stepfun").success).toBe(true); + expect(ModelProviderSchema.safeParse("xiaomi").success).toBe(true); + + expect(canonicalCatalogProviderId("stepfun")).toBe("stepfun"); + expect(canonicalCatalogProviderId("xiaomi")).toBe("xiaomi_mimo"); + expect(canonicalCatalogProviderId("xiaomi_mimo")).toBe("xiaomi_mimo"); + expect(canonicalCatalogProviderId("XIAOMI")).toBe("xiaomi_mimo"); + expect(canonicalCatalogProviderId("xiaomimimo")).toBeNull(); + + // Neither provider is wired into image generation yet. + for (const provider of ["stepfun", "xiaomi"]) { + expect( + ImageGenModelConfigInputSchema.safeParse({ + provider, + baseUrl: "https://example.com/v1", + modelId: "x" + }).success + ).toBe(false); + } + }); + it("limits newly saved model names without constraining normal names", () => { const modelInput = { endpointId: "primary", diff --git a/App/backend/src/tests/project-version.test.ts b/App/backend/src/tests/project-version.test.ts index 96f1b2f9f..2baa49e3f 100644 --- a/App/backend/src/tests/project-version.test.ts +++ b/App/backend/src/tests/project-version.test.ts @@ -13,6 +13,8 @@ describe("project version", () => { expect(MEMMY_VERSION).toBe(rootManifest.version); expect(MEMMY_VERSION).toBe("1.1.5"); expect(MEMMY_VERSION).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); + expect(readFileSync(resolve(repoRoot, "App/backend/src/project-version.ts"), "utf8")) + .toContain(`MEMMY_VERSION = ${JSON.stringify(rootManifest.version)}`); for (const path of ["App/shell/desktop/package.json", "App/memmy-agent/package.json"]) { const consumer = JSON.parse(readFileSync(resolve(repoRoot, path), "utf8")); expect(MEMMY_VERSION, path).toBe(consumer.version); diff --git a/App/backend/src/tests/support/mock-cloud-client.ts b/App/backend/src/tests/support/mock-cloud-client.ts index 0892ea2fa..2c3e36b0e 100644 --- a/App/backend/src/tests/support/mock-cloud-client.ts +++ b/App/backend/src/tests/support/mock-cloud-client.ts @@ -16,6 +16,8 @@ import type { TokenQuotaEligibility, CloudLoginInput, CloudLogoutInput, + CloudSocialLoginCredentials, + CloudStartSocialLoginInput, SendEmailCodeInput, SendPhoneCodeInput, SendTelemetryInput, @@ -109,6 +111,22 @@ export function createMockCloudClient(options: CreateMockCloudClientOptions = {} }; }, + async startSocialLogin(input: CloudStartSocialLoginInput) { + return { + flowId: "mock-social-flow-id", + pollToken: "mock-social-poll-token-000000000000", + authorizationUrl: input.provider === "google" + ? "https://accounts.google.com/o/oauth2/v2/auth" + : "https://github.com/login/oauth/authorize", + expiresInSec: 600, + pollIntervalSec: 2 + }; + }, + + async getSocialLoginStatus(_input: CloudSocialLoginCredentials) { + return { status: "pending" as const }; + }, + async logout(_input: CloudLogoutInput) { return undefined; }, diff --git a/App/backend/src/tests/support/mock-memory-client.ts b/App/backend/src/tests/support/mock-memory-client.ts index b65f84c68..870bc36a7 100644 --- a/App/backend/src/tests/support/mock-memory-client.ts +++ b/App/backend/src/tests/support/mock-memory-client.ts @@ -128,6 +128,27 @@ export function createMockMemoryClient(options: CreateMockMemoryClientOptions = }; }, + async completeSourceTurn(input) { + failIfNeeded(); + const l1MemoryId = randomUUID(); + return { + status: "stored", + result: { + turnId: input.sourceTurn.turnId, + sessionId: input.sessionId ?? randomUUID(), + episodeId: randomUUID(), + rawTurnId: randomUUID(), + l1MemoryId, + l1MemoryIds: [l1MemoryId], + closedEpisodeIds: [], + scheduledEvolution: false, + jobs: [], + ...nextChange(), + serverTime: now() + } + }; + }, + async search(input) { failIfNeeded(); const injectedContext = { diff --git a/App/frontend/desktop/src/analytics/analytics-events.ts b/App/frontend/desktop/src/analytics/analytics-events.ts index 1d7e07fe8..bf7ca3362 100644 --- a/App/frontend/desktop/src/analytics/analytics-events.ts +++ b/App/frontend/desktop/src/analytics/analytics-events.ts @@ -36,7 +36,7 @@ export interface FeatureEvent { export interface SignupCompletedEvent { name: "signup_completed"; params: { - method: "phone" | "email"; + method: "phone" | "email" | "google" | "github"; is_new_user: boolean; user_mode: "account"; invite_code_provided: boolean; diff --git a/App/frontend/desktop/src/analytics/page-view.ts b/App/frontend/desktop/src/analytics/page-view.ts index b9d398794..8932fc961 100644 --- a/App/frontend/desktop/src/analytics/page-view.ts +++ b/App/frontend/desktop/src/analytics/page-view.ts @@ -21,6 +21,7 @@ const ROUTE_PAGE_TITLES: Record = { const MEMORY_SUB_PAGE_TITLES: Record = { overview: "Overview", + "computer-history": "Computer History", memories: "Memories", "user-memories": "User Memories", tasks: "Tasks", diff --git a/App/frontend/desktop/src/api/account-client.ts b/App/frontend/desktop/src/api/account-client.ts index f52a3396a..e6a2e310e 100644 --- a/App/frontend/desktop/src/api/account-client.ts +++ b/App/frontend/desktop/src/api/account-client.ts @@ -6,6 +6,10 @@ import { OkResponseSchema, SendCodeInputSchema, SendCodeResponseSchema, + SocialLoginStatusInputSchema, + SocialLoginStatusResponseSchema, + StartSocialLoginInputSchema, + StartSocialLoginResponseSchema, UpdateAccountProfileInputSchema, VerifyCodeInputSchema, type AccountInvitationView, @@ -16,6 +20,10 @@ import { type RuntimeConfig, type SendCodeInput, type SendCodeResponse, + type SocialLoginStatusInput, + type SocialLoginStatusResponse, + type StartSocialLoginInput, + type StartSocialLoginResponse, type UpdateAccountProfileInput, type VerifyCodeInput } from "@memmy/local-api-contracts"; @@ -52,6 +60,8 @@ export type AccountCodeValidationResult = export interface AccountClient { sendCode(input: SendCodeInput): Promise; verifyCode(input: VerifyCodeInput): Promise; + startSocialLogin(input: StartSocialLoginInput): Promise; + getSocialLoginStatus(input: SocialLoginStatusInput): Promise; getInvitation(): Promise; updateProfile(input: UpdateAccountProfileInput): Promise; markGuideFinished(): Promise; @@ -79,6 +89,24 @@ export function createHttpAccountClient(config: RuntimeConfig): AccountClient { }); }, + async startSocialLogin(input) { + return requestJson({ + config, + path: "/api/account/oauth/start", + schema: StartSocialLoginResponseSchema, + body: StartSocialLoginInputSchema.parse(input) + }); + }, + + async getSocialLoginStatus(input) { + return requestJson({ + config, + path: "/api/account/oauth/status", + schema: SocialLoginStatusResponseSchema, + body: SocialLoginStatusInputSchema.parse(input) + }); + }, + async getInvitation() { return requestJson({ config, diff --git a/App/frontend/desktop/src/api/computer-history-contract.ts b/App/frontend/desktop/src/api/computer-history-contract.ts new file mode 100644 index 000000000..3ee3e7a81 --- /dev/null +++ b/App/frontend/desktop/src/api/computer-history-contract.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +export const ComputerHistoryPermissionsSchema = z.object({ + supported: z.boolean(), + accessibility: z.boolean(), + inputMonitoring: z.boolean(), +}).strict(); +export type ComputerHistoryPermissions = z.infer; +export type ComputerHistoryPermission = "accessibility" | "inputMonitoring"; + +// The Computer History snapshot contract. +// +// This lives on its own, free of any browser dependency, so the agent that +// produces the snapshot can assert against the very schema the desktop client +// validates with. Keeping it inside the client meant the only way to check the +// contract was to import the browser bundle, and the two sides drifted twice +// before anything noticed. + +export const ComputerHistoryEntrySchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + applications: z.array(z.string()), + summaryWindow: z.enum(["10min", "6h"]).nullable(), + // Keep segments visible when neither metadata nor legacy citations prove coverage. + coveredHistoryIds: z.array(z.string()).default([]), + pinned: z.boolean(), + eventStreamPath: z.string().nullable(), + sourceType: z.enum(["captured", "rollup", "imported", "demo_fixture"]), + createdAt: z.string(), + // Left out of what the desktop client receives: the timeline never renders a + // summary's body, and it was most of every response. The agent reads bodies + // in process. + markdown: z.string().optional(), + filePath: z.string(), + replayPlan: z.object({ + sourcePath: z.string(), + sourceHash: z.string(), + status: z.enum(["ready", "not_replayable"]), + steps: z.array(z.string()), + variables: z.array(z.string()) + }).nullable().optional() +}).strict(); + +export const ComputerHistoryWorkflowSchema = z.object({ + id: z.string(), + title: z.string(), + createdAt: z.string(), + markdown: z.string(), + filePath: z.string(), + sourceHistoryId: z.string().nullable() +}).strict(); + +// Exported so the agent can assert its snapshot still satisfies the contract +// this client enforces. The schema is strict, so a field added on one side and +// not the other breaks the page rather than being ignored. +export const ComputerHistorySnapshotSchema = z.object({ + observation: z.object({ + state: z.enum(["running", "paused", "stopped", "stopping", "failed"]), + startedAt: z.string().nullable(), + segmentId: z.string().nullable(), + segmentStartedAt: z.string().nullable(), + error: z.string().nullable(), + narrationError: z.string().nullable(), + narrationErrorCategory: z.literal("quota_exhausted").nullable().optional(), + modelSource: z.enum(["account", "byok"]).nullable().optional(), + permissions: ComputerHistoryPermissionsSchema.optional(), + }).strict(), + histories: z.array(ComputerHistoryEntrySchema), + workflows: z.array(ComputerHistoryWorkflowSchema), + privacy: z.object({ + screenshots: z.literal(false), + audio: z.literal(false), + rawRetentionHours: z.number(), + markdownDirectory: z.string(), + eventStreamDirectory: z.string(), + + }).strict() +}).strict(); + +// The app-icon route answers with one image rather than a snapshot, so it +// carries its own tiny schema next to the one it sits beside. +export const ApplicationIconSchema = z.object({ + icon: z.string().nullable() +}).strict(); diff --git a/App/frontend/desktop/src/api/memmy-agent-client.ts b/App/frontend/desktop/src/api/memmy-agent-client.ts index f6def0b23..a22416083 100644 --- a/App/frontend/desktop/src/api/memmy-agent-client.ts +++ b/App/frontend/desktop/src/api/memmy-agent-client.ts @@ -6,6 +6,15 @@ * bearer tokens and a WebSocket protocol owned by memmy-agent. */ import { z } from "zod"; +import { + ApplicationIconSchema, + ComputerHistorySnapshotSchema, + ComputerHistoryPermissionsSchema, + type ComputerHistoryPermission, + type ComputerHistoryPermissions, +} from "./computer-history-contract.js"; + +export { ComputerHistorySnapshotSchema }; export type AgentGoalStatus = | "active" @@ -43,6 +52,65 @@ export type AgentGoalControlResult = { warning?: "turn_cancel_failed"; }; +export type ComputerHistorySourceType = "captured" | "rollup" | "imported" | "demo_fixture"; + +export type ComputerHistoryReplayPlan = { + sourcePath: string; + sourceHash: string; + status: "ready" | "not_replayable"; + steps: string[]; + variables: string[]; +}; + +export type ComputerHistoryEntry = { + description: string | null; + applications: string[]; + summaryWindow: "10min" | "6h" | null; + coveredHistoryIds: string[]; + pinned: boolean; + eventStreamPath: string | null; + id: string; + title: string; + sourceType: ComputerHistorySourceType; + createdAt: string; + /** Not sent to the client: the timeline shows the description, never the body. */ + markdown?: string; + filePath: string; + replayPlan?: ComputerHistoryReplayPlan | null; +}; + +export type ComputerHistoryWorkflow = { + id: string; + title: string; + createdAt: string; + markdown: string; + filePath: string; + sourceHistoryId: string | null; +}; + +export type ComputerHistorySnapshot = { + observation: { + state: "running" | "paused" | "stopped" | "stopping" | "failed"; + startedAt: string | null; + segmentId: string | null; + segmentStartedAt: string | null; + error: string | null; + narrationError: string | null; + narrationErrorCategory?: "quota_exhausted" | null; + modelSource?: "account" | "byok" | null; + permissions?: ComputerHistoryPermissions; + }; + histories: ComputerHistoryEntry[]; + workflows: ComputerHistoryWorkflow[]; + privacy: { + screenshots: false; + audio: false; + rawRetentionHours: number; + markdownDirectory: string; + eventStreamDirectory: string; + }; +}; + const AgentGoalStateSchema = z.object({ goal_id: z.string().nullable(), status: z.union([ @@ -639,6 +707,20 @@ export type MemmyAgentRunLifecycleEvent = MemmyAgentWsEvent & { export interface MemmyAgentClient { bootstrap(options?: { force?: boolean }): Promise; getSettings(): Promise; + getComputerHistory(): Promise; + setComputerHistoryModel(preset: string | null): Promise; + checkComputerHistoryPermissions(): Promise; + openComputerHistoryPermission(permission: ComputerHistoryPermission, mode?: "request" | "settings"): Promise; + deleteComputerHistory(historyId: string): Promise; + clearComputerHistories(scope: "today" | "all"): Promise; + pinComputerHistory(historyId: string, pinned: boolean): Promise; + importComputerHistory(input: { title?: string; markdown: string }): Promise; + startComputerHistoryObservation(): Promise; + pauseComputerHistoryObservation(): Promise; + resumeComputerHistoryObservation(): Promise; + stopComputerHistoryObservation(): Promise; + createComputerHistoryWorkflow(historyId: string, userRequest: string): Promise; + getApplicationIcon(bundleId: string): Promise; getSessionSnapshot(options?: MemmyAgentRequestOptions): Promise; listSessions(): Promise; readWorkspaceEnvironment(scope: WorkspaceEnvironmentScope): Promise; @@ -978,6 +1060,81 @@ class HttpMemmyAgentClient implements MemmyAgentClient { return this.request("/api/settings", AgentSettingsSchema); } + async setComputerHistoryModel(preset: string | null): Promise { + return this.request("/api/computer-history/model", ComputerHistorySnapshotSchema, { + method: "POST", body: { model_preset: preset }, + }); + } + + async getComputerHistory(): Promise { + return this.request("/api/computer-history", ComputerHistorySnapshotSchema); + } + + async deleteComputerHistory(historyId: string): Promise { + return this.request("/api/computer-history/delete", ComputerHistorySnapshotSchema, { + method: "POST", + body: { history_id: historyId } + }); + } + + async clearComputerHistories(scope: "today" | "all"): Promise { + return this.request("/api/computer-history/clear", ComputerHistorySnapshotSchema, { + method: "POST", + body: { scope } + }); + } + + async pinComputerHistory(historyId: string, pinned: boolean): Promise { + return this.request("/api/computer-history/pin", ComputerHistorySnapshotSchema, { + method: "POST", + body: { history_id: historyId, pinned } + }); + } + + + async importComputerHistory(input: { title?: string; markdown: string }): Promise { + return this.request("/api/computer-history/import", ComputerHistorySnapshotSchema, { method: "POST", body: input }); + } + + async startComputerHistoryObservation(): Promise { + return this.request("/api/computer-history/observation/start", ComputerHistorySnapshotSchema, { method: "POST", body: {} }); + } + + async checkComputerHistoryPermissions() { + return this.request("/api/computer-history/permissions/check", ComputerHistoryPermissionsSchema, { method: "POST", body: {} }); + } + + async openComputerHistoryPermission(permission: ComputerHistoryPermission, mode: "request" | "settings" = "settings") { + return this.request("/api/computer-history/permissions/open", ComputerHistoryPermissionsSchema, { method: "POST", body: { permission, mode } }); + } + + async pauseComputerHistoryObservation(): Promise { + return this.request("/api/computer-history/observation/pause", ComputerHistorySnapshotSchema, { method: "POST", body: {} }); + } + + async resumeComputerHistoryObservation(): Promise { + return this.request("/api/computer-history/observation/resume", ComputerHistorySnapshotSchema, { method: "POST", body: {} }); + } + + async stopComputerHistoryObservation(): Promise { + return this.request("/api/computer-history/observation/stop", ComputerHistorySnapshotSchema, { method: "POST", body: {} }); + } + + async createComputerHistoryWorkflow(historyId: string, userRequest: string): Promise { + return this.request("/api/computer-history/workflows/create", ComputerHistorySnapshotSchema, { + method: "POST", + body: { history_id: historyId, user_request: userRequest } + }); + } + + async getApplicationIcon(bundleId: string): Promise { + const { icon } = await this.request( + `/api/computer-history/app-icon?bundle_id=${encodeURIComponent(bundleId)}`, + ApplicationIconSchema + ); + return icon; + } + async listSessions(): Promise { return (await this.getSessionSnapshot()).sessions; } diff --git a/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts b/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts index f6aff2536..63f775c70 100644 --- a/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts +++ b/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts @@ -81,6 +81,43 @@ afterEach(() => { }); describe("memmy-agent client", () => { + it("syncs the history model and retains its actual model source in the response", async () => { + const snapshot = { + observation: { state: "running", startedAt: null, segmentId: null, segmentStartedAt: null, error: null, narrationError: null, modelSource: "byok" }, + histories: [], workflows: [], + privacy: { screenshots: false, audio: false, rawRetentionHours: 48, markdownDirectory: "/tmp/histories", eventStreamDirectory: "/tmp/segments" }, + }; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + if (url.pathname === "/webui/bootstrap") return json(bootstrap); + expect(url.pathname).toBe("/api/computer-history/model"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ model_preset: "my-api-model" }); + return json(snapshot); + }); + const client = createMemmyAgentClient({ baseUrl: "http://127.0.0.1:18980", fetchFn: fetchMock as typeof fetch }); + await expect(client.setComputerHistoryModel("my-api-model")).resolves.toEqual(snapshot); + }); + + it.each(["today", "all"] as const)("clears %s Computer History with the server-side clear endpoint", async (scope) => { + const snapshot = { + observation: { state: "stopped", startedAt: null, segmentId: null, segmentStartedAt: null, error: null, narrationError: null }, + histories: [], + workflows: [], + privacy: { screenshots: false, audio: false, rawRetentionHours: 48, markdownDirectory: "/tmp/histories", eventStreamDirectory: "/tmp/segments" }, + }; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + if (url.pathname === "/webui/bootstrap") return json(bootstrap); + expect(url.pathname).toBe("/api/computer-history/clear"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ scope }); + return json(snapshot); + }); + const client = createMemmyAgentClient({ baseUrl: "http://127.0.0.1:18980", fetchFn: fetchMock as typeof fetch }); + await expect(client.clearComputerHistories(scope)).resolves.toEqual(snapshot); + }); + it("reads the workspace snapshot, changed files, and a selected diff", async () => { const calls: string[] = []; const workspaceState = (project: boolean, branch = "zy_git_v1.0.7") => ({ diff --git a/App/frontend/desktop/src/app.tsx b/App/frontend/desktop/src/app.tsx index 51a6ec6bb..73856f1be 100644 --- a/App/frontend/desktop/src/app.tsx +++ b/App/frontend/desktop/src/app.tsx @@ -1,3 +1,5 @@ +import { useComputerHistoryModelSync } from "./app/computer-history-model-sync.js"; +import { isComputerHistorySupported } from "./app/computer-history-platform.js"; /** App module. */ import { SseEventSchema, type AccountSessionView, type SseEvent } from "@memmy/local-api-contracts"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -35,6 +37,7 @@ import { createAppClients } from "./api/client-types.js"; import { createEventsConnection } from "./api/events.js"; import { MemmyAgentRequestError, type MemmyAgentClient } from "./api/memmy-agent-client.js"; import { getRuntimeConfig } from "./api/runtime-config.js"; +import { readHistoryPermissionSetup } from "./pages/memory/computer-history-permission-state.js"; import { clearMemoryPanelCache } from "./pages/memory/memory-panel-cache.js"; import { readLocalNickname } from "./app/nickname.js"; import { @@ -65,6 +68,14 @@ export function App() { function RuntimeApp() { const { state, dispatch } = useAppState(); const { clients, setClients } = useApiClients(); + const historyModelScope = state.agent.currentChatId ?? "draft-new-task"; + useComputerHistoryModelSync({ + client: clients?.memmyAgent ?? null, + enabled: Boolean(state.bootstrap && state.modelConfig), + preset: state.agent.pendingPresetByScope[historyModelScope] + ?? state.agent.committedModelSelectionByScope[historyModelScope]?.presetId ?? null, + revision: JSON.stringify([state.bootstrap?.app.userMode, state.account.userId, state.modelConfig?.configRevision]), + }); const { track } = useAnalytics(); const { t } = useTranslation(); const translationRef = useRef(t); @@ -191,7 +202,7 @@ function RuntimeApp() { const initialPath = resolveLaunchInitialView({ defaultPath: defaultInitialPath, currentRoute, - launchRouteOverride, + launchRouteOverride: launchRouteOverride ?? (isComputerHistorySupported() && readHistoryPermissionSetup() && launchModeOverride !== "pet" ? "/memory" : null), launchModeOverride, petIntent }); diff --git a/App/frontend/desktop/src/app/computer-history-model-sync.ts b/App/frontend/desktop/src/app/computer-history-model-sync.ts new file mode 100644 index 000000000..1ef0fdae5 --- /dev/null +++ b/App/frontend/desktop/src/app/computer-history-model-sync.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef } from "react"; +import type { MemmyAgentClient } from "../api/memmy-agent-client.js"; +import { isComputerHistorySupported } from "./computer-history-platform.js"; + +/** Keep background summaries on the selected task model, even outside History. */ +export function useComputerHistoryModelSync(input: { + client: Pick | null; + enabled: boolean; + preset: string | null; + revision: string; +}): void { + // Serialize across selection changes so a slower old request cannot become + // the final selection. Intermediate selections that never started are skipped. + const queue = useRef(Promise.resolve()); + const { client, preset, revision } = input; + const enabled = input.enabled && isComputerHistorySupported(); + useEffect(() => { + if (!client || !enabled) return; + let disposed = false; + let queued = false; + let timer: ReturnType | undefined; + const sync = () => { + if (disposed || queued) return; + // Full and pet windows can coexist. Only the focused window owns the + // current selection; an inactive window must not restore its stale model. + if (!document.hasFocus()) { + clearTimeout(timer); + timer = setTimeout(sync, 30_000); + return; + } + queued = true; + clearTimeout(timer); + queue.current = queue.current.catch(() => {}).then(async () => { + if (disposed) return; + let delay = 30_000; + try { + await client.setComputerHistoryModel(preset); + } catch { + // Reconnect/retry without changing the user's chat selection. + delay = 5_000; + } finally { + queued = false; + if (!disposed) timer = setTimeout(sync, delay); + } + }); + }; + sync(); + window.addEventListener("focus", sync); + return () => { + disposed = true; + clearTimeout(timer); + window.removeEventListener("focus", sync); + }; + }, [client, enabled, preset, revision]); +} diff --git a/App/frontend/desktop/src/app/computer-history-platform.ts b/App/frontend/desktop/src/app/computer-history-platform.ts new file mode 100644 index 000000000..24e96ba93 --- /dev/null +++ b/App/frontend/desktop/src/app/computer-history-platform.ts @@ -0,0 +1,6 @@ +/** History records macOS activity. Require the desktop host's platform, not the browser's OS. */ +export function isComputerHistorySupported( + platform = typeof window === "undefined" ? undefined : window.memmy?.platform, +): boolean { + return platform === "darwin"; +} diff --git a/App/frontend/desktop/src/app/invitation-analytics.ts b/App/frontend/desktop/src/app/invitation-analytics.ts index 0691aa703..6b21c7d60 100644 --- a/App/frontend/desktop/src/app/invitation-analytics.ts +++ b/App/frontend/desktop/src/app/invitation-analytics.ts @@ -1,4 +1,4 @@ -import type { AccountChannel, InvitationResult } from "@memmy/local-api-contracts"; +import type { AccountChannel, InvitationResult, SocialLoginProvider } from "@memmy/local-api-contracts"; import type { AnalyticsEvent, InviteCodeCopiedEvent, @@ -9,7 +9,7 @@ import type { export type TrackAnalyticsEvent = (event: AnalyticsEvent) => void; export interface InvitationSignupEventInput { - channel: AccountChannel; + channel: AccountChannel | SocialLoginProvider; isNewUser: boolean; invitationCode?: string; } diff --git a/App/frontend/desktop/src/app/tests/computer-history-model-sync.test.tsx b/App/frontend/desktop/src/app/tests/computer-history-model-sync.test.tsx new file mode 100644 index 000000000..0f3d930f2 --- /dev/null +++ b/App/frontend/desktop/src/app/tests/computer-history-model-sync.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { useComputerHistoryModelSync } from "../computer-history-model-sync.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +let root: Root; +let host: HTMLDivElement; +function Harness(props: Parameters[0]) { + useComputerHistoryModelSync(props); + return null; +} +beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(window, "memmy", { configurable: true, value: { platform: "darwin" } }); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(() => { act(() => root.unmount()); host.remove(); delete window.memmy; vi.useRealTimers(); vi.restoreAllMocks(); }); + +it("syncs default and explicit models, refreshes settings changes and stops on unmount", async () => { + const client = { setComputerHistoryModel: vi.fn().mockResolvedValue({}) }; + const render = (preset: string | null, revision = "account-v1") => act(async () => { + root.render(); + }); + await render(null); + expect(client.setComputerHistoryModel).toHaveBeenLastCalledWith(null); + await render("custom"); + expect(client.setComputerHistoryModel).toHaveBeenLastCalledWith("custom"); + await render("custom", "account-v2"); + expect(client.setComputerHistoryModel).toHaveBeenCalledTimes(3); + await act(async () => vi.advanceTimersByTimeAsync(30_000)); + expect(client.setComputerHistoryModel).toHaveBeenCalledTimes(4); + await act(async () => root.render(null)); + await act(async () => vi.advanceTimersByTimeAsync(30_000)); + expect(client.setComputerHistoryModel).toHaveBeenCalledTimes(4); +}); + +it("serializes rapid model changes and skips superseded queued choices", async () => { + let finish!: (value: unknown) => void; + const client = { setComputerHistoryModel: vi.fn().mockReturnValueOnce(new Promise(resolve => { finish = resolve; })).mockResolvedValue({}) }; + const render = (preset: string) => act(async () => { + root.render(); + }); + await render("account"); + await render("custom-a"); + await render("custom-b"); + expect(client.setComputerHistoryModel).toHaveBeenCalledTimes(1); + await act(async () => finish({})); + expect(client.setComputerHistoryModel.mock.calls.map(([preset]) => preset)).toEqual(["account", "custom-b"]); +}); + +it("retries failed synchronization and does not sync before bootstrap is ready", async () => { + const client = { setComputerHistoryModel: vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue({}) }; + await act(async () => root.render()); + expect(client.setComputerHistoryModel).not.toHaveBeenCalled(); + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTimeAsync(5000)); + expect(client.setComputerHistoryModel).toHaveBeenCalledTimes(2); + expect(client.setComputerHistoryModel).toHaveBeenLastCalledWith("custom"); +}); + +it("does not let an inactive window overwrite the selected model", async () => { + vi.mocked(document.hasFocus).mockReturnValue(false); + const client = { setComputerHistoryModel: vi.fn().mockResolvedValue({}) }; + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTimeAsync(30_000)); + expect(client.setComputerHistoryModel).not.toHaveBeenCalled(); + vi.mocked(document.hasFocus).mockReturnValue(true); + await act(async () => window.dispatchEvent(new Event("focus"))); + expect(client.setComputerHistoryModel).toHaveBeenCalledOnce(); +}); + +it.each(["win32", "linux", undefined])("never syncs or retries History on an unsupported/unknown host: %s", async (platform) => { + Object.defineProperty(window, "memmy", { configurable: true, value: platform ? { platform } : undefined }); + const client = { setComputerHistoryModel: vi.fn().mockRejectedValue(new Error("unsupported")) }; + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTimeAsync(60_000)); + await act(async () => window.dispatchEvent(new Event("focus"))); + await act(async () => root.render()); + expect(client.setComputerHistoryModel).not.toHaveBeenCalled(); +}); diff --git a/App/frontend/desktop/src/app/tests/update-coordinator.test.tsx b/App/frontend/desktop/src/app/tests/update-coordinator.test.tsx index f4e990c7e..53b7369d1 100644 --- a/App/frontend/desktop/src/app/tests/update-coordinator.test.tsx +++ b/App/frontend/desktop/src/app/tests/update-coordinator.test.tsx @@ -104,7 +104,7 @@ describe("UpdateCoordinatorProvider", () => { }); expect(getButtonByText("重启安装")).not.toBeNull(); - expect(checkForUpdates).toHaveBeenCalledTimes(1); + expect(checkForUpdates).toHaveBeenCalledTimes(2); expect(downloadUpdate).toHaveBeenCalledTimes(1); }); @@ -169,6 +169,75 @@ describe("UpdateCoordinatorProvider", () => { }); expect(readOutput("phase")).toBe("prepared"); expect(container.textContent).not.toContain("安装包已准备好,是否重启并安装更新?"); + expect(checkForUpdates).toHaveBeenCalledTimes(2); + }); + + it("refreshes the manifest before download and skips an intermediate release", async () => { + const checkForUpdates = vi.fn() + .mockResolvedValueOnce({ + status: "available" as const, + currentVersion: "1.1.2", + latestVersion: "1.1.3", + downloadUrl: "https://updates.example.com/Memmy-1.1.3.dmg" + }) + .mockResolvedValueOnce({ + status: "available" as const, + currentVersion: "1.1.2", + latestVersion: "1.1.4", + downloadUrl: "https://updates.example.com/Memmy-1.1.4.dmg" + }); + const downloadUpdate = vi.fn(async () => ({ + filePath: "/tmp/Memmy-1.1.4.dmg", + opened: false + })); + setDesktopBridge({ + platform: "darwin", + getAppInfo: vi.fn(async () => ({ + name: "Memmy", + version: "1.1.2", + platform: "darwin", + arch: "arm64", + isPackaged: true, + isWindowsStore: false + })), + checkForUpdates, + downloadUpdate + }); + + await act(async () => { + root.render( + + + + + + + + ); + }); + + await act(async () => { + getButtonByLabel("update-action").click(); + await Promise.resolve(); + }); + expect(document.body.textContent).toContain("1.1.3"); + + await act(async () => { + getButtonByText("下载更新").click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(checkForUpdates).toHaveBeenCalledTimes(2); + expect(downloadUpdate).toHaveBeenCalledTimes(1); + expect(downloadUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + latestVersion: "1.1.4", + downloadUrl: "https://updates.example.com/Memmy-1.1.4.dmg" + }), + { openInstaller: false } + ); + expect(readOutput("prepared-path")).toBe("/tmp/Memmy-1.1.4.dmg"); }); it("keeps the prepared installer path when launching the installer fails", async () => { diff --git a/App/frontend/desktop/src/app/update-coordinator.tsx b/App/frontend/desktop/src/app/update-coordinator.tsx index ae7bf34c9..200ec0bb8 100644 --- a/App/frontend/desktop/src/app/update-coordinator.tsx +++ b/App/frontend/desktop/src/app/update-coordinator.tsx @@ -264,6 +264,76 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { } }, [commitUpdateState]); + /** Rechecks the manifest immediately before downloading so a stale update prompt cannot install an intermediate release. */ + const downloadLatestUpdate = useCallback(async (options: DownloadUpdateOptions = {}): Promise => { + commitUpdateState((current) => ({ + ...current, + phase: "checking", + dialog: null, + downloadProgress: null, + feedback: null + })); + + try { + const result = await requestUpdateResult(); + if (!mountedRef.current) { + return; + } + + if (result.status === "not-configured") { + commitUpdateState(() => ({ + phase: "not-configured", + result, + preparedUpdatePath: null, + downloadProgress: null, + feedback: { key: "settings.about.updateNotConfigured" }, + dialog: null + })); + return; + } + + if (result.status === "latest") { + commitUpdateState(() => ({ + phase: "latest", + result, + preparedUpdatePath: null, + downloadProgress: null, + feedback: { key: "settings.about.upToDate", values: { version: result.currentVersion } }, + dialog: null + })); + return; + } + + const version = result.latestVersion ?? result.currentVersion; + if (result.preparedUpdatePath) { + commitUpdateState(() => ({ + phase: "prepared", + result, + preparedUpdatePath: result.preparedUpdatePath ?? null, + downloadProgress: null, + feedback: { key: "settings.about.silentReady", values: { version } }, + dialog: options.showInstallDialog === false ? null : "install-confirm" + })); + return; + } + + await downloadUpdate(result, options); + } catch (error) { + if (!mountedRef.current) { + return; + } + console.warn("refresh app update before download failed", error); + commitUpdateState(() => ({ + phase: "error", + result: null, + preparedUpdatePath: null, + downloadProgress: null, + feedback: { key: "settings.about.updateCheckFailed" }, + dialog: null + })); + } + }, [commitUpdateState, downloadUpdate, requestUpdateResult]); + const installPreparedUpdate = useCallback(async (): Promise => { const current = updateStateRef.current; const preparedPath = current.preparedUpdatePath; @@ -436,12 +506,12 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { return; } if (current.phase === "available" && current.result?.downloadUrl) { - await downloadUpdate(current.result, { showInstallDialog: false }); + await downloadLatestUpdate({ showInstallDialog: false }); return; } await checkManually(); - }, [checkManually, downloadUpdate, installPreparedUpdate]); + }, [checkManually, downloadLatestUpdate, installPreparedUpdate]); const dismissDialog = useCallback(() => { commitUpdateState((state) => ({ ...state, dialog: null })); @@ -454,9 +524,9 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { return; } if (current.dialog === "download-confirm" && current.result) { - await downloadUpdate(current.result); + await downloadLatestUpdate(); } - }, [downloadUpdate, installPreparedUpdate]); + }, [downloadLatestUpdate, installPreparedUpdate]); const commitPassiveAvailableUpdate = useCallback((result: DesktopUpdateCheckResult): void => { const version = result.latestVersion ?? result.currentVersion; diff --git a/App/frontend/desktop/src/assets/llm-provider-logo/stepfun.svg b/App/frontend/desktop/src/assets/llm-provider-logo/stepfun.svg new file mode 100644 index 000000000..0d4a25076 --- /dev/null +++ b/App/frontend/desktop/src/assets/llm-provider-logo/stepfun.svg @@ -0,0 +1 @@ +StepFun \ No newline at end of file diff --git a/App/frontend/desktop/src/assets/llm-provider-logo/xiaomi.svg b/App/frontend/desktop/src/assets/llm-provider-logo/xiaomi.svg new file mode 100644 index 000000000..b8ba03b87 --- /dev/null +++ b/App/frontend/desktop/src/assets/llm-provider-logo/xiaomi.svg @@ -0,0 +1 @@ +XiaomiMiMo \ No newline at end of file diff --git a/App/frontend/desktop/src/assets/model-logos/stepfun.svg b/App/frontend/desktop/src/assets/model-logos/stepfun.svg new file mode 100644 index 000000000..0d4a25076 --- /dev/null +++ b/App/frontend/desktop/src/assets/model-logos/stepfun.svg @@ -0,0 +1 @@ +StepFun \ No newline at end of file diff --git a/App/frontend/desktop/src/assets/model-logos/xiaomi.svg b/App/frontend/desktop/src/assets/model-logos/xiaomi.svg new file mode 100644 index 000000000..b8ba03b87 --- /dev/null +++ b/App/frontend/desktop/src/assets/model-logos/xiaomi.svg @@ -0,0 +1 @@ +XiaomiMiMo \ No newline at end of file diff --git a/App/frontend/desktop/src/components/modal.tsx b/App/frontend/desktop/src/components/modal.tsx index eb3ba58d4..7fd92c4fe 100644 --- a/App/frontend/desktop/src/components/modal.tsx +++ b/App/frontend/desktop/src/components/modal.tsx @@ -21,6 +21,7 @@ export interface ModalProps { showHeader?: boolean; showCloseButton?: boolean; closeLabel?: string; + closeDisabled?: boolean; closeContent?: ReactNode; className?: string; backdropClassName?: string; @@ -36,6 +37,7 @@ export interface ModalProps { export function Modal(props: ModalProps) { const titleId = useId(); const dialogRef = useRef(null); + const backdropPressRef = useRef(false); const wasOpenRef = useRef(false); const showHeader = props.showHeader ?? true; const showCloseButton = props.showCloseButton ?? Boolean(props.onClose); @@ -82,7 +84,22 @@ export function Modal(props: ModalProps) {
shouldCloseModalByBackdrop(event) && props.onClose?.()} + onPointerDown={(event) => { + backdropPressRef.current = event.target === event.currentTarget; + }} + onPointerUp={(event) => { + backdropPressRef.current = backdropPressRef.current && event.target === event.currentTarget; + }} + onPointerCancel={() => { + backdropPressRef.current = false; + }} + onClick={(event) => { + const shouldClose = backdropPressRef.current && shouldCloseModalByBackdrop(event); + backdropPressRef.current = false; + if (shouldClose) { + props.onClose?.(); + } + }} >
{props.closeContent ?? props.closeLabel ?? "Close"} diff --git a/App/frontend/desktop/src/components/model-provider-logo.tsx b/App/frontend/desktop/src/components/model-provider-logo.tsx index bb9a18cc7..7cfea1439 100644 --- a/App/frontend/desktop/src/components/model-provider-logo.tsx +++ b/App/frontend/desktop/src/components/model-provider-logo.tsx @@ -8,6 +8,8 @@ import minimaxLogoUrl from "../assets/model-logos/minimax.svg"; import moonshotLogoUrl from "../assets/model-logos/moonshot.svg"; import openaiLogoUrl from "../assets/model-logos/openai.svg"; import qwenLogoUrl from "../assets/model-logos/qwen.svg"; +import stepfunLogoUrl from "../assets/model-logos/stepfun.svg"; +import xiaomiLogoUrl from "../assets/model-logos/xiaomi.svg"; import zhipuLogoUrl from "../assets/model-logos/zhipu.svg"; import type { Protocol } from "../pages/model-config.js"; @@ -23,7 +25,9 @@ const PROTOCOL_LOGO_URLS: Record = { moonshot: moonshotLogoUrl, minimax: minimaxLogoUrl, baidu: baiduLogoUrl, - doubao: doubaoLogoUrl + doubao: doubaoLogoUrl, + stepfun: stepfunLogoUrl, + xiaomi: xiaomiLogoUrl }; /** Resolve a logo URL for a protocol or free-form provider string. */ @@ -55,6 +59,8 @@ export function modelProviderLogoUrl(provider: string): string | undefined { if (key.includes("doubao") || key.includes("bytedance") || key.includes("volc") || key.includes("ark")) { return PROTOCOL_LOGO_URLS.doubao; } + if (key.includes("stepfun") || key.includes("step-") || key.includes("阶跃")) return PROTOCOL_LOGO_URLS.stepfun; + if (key.includes("xiaomi") || key.includes("mimo") || key.includes("小米")) return PROTOCOL_LOGO_URLS.xiaomi; return undefined; } diff --git a/App/frontend/desktop/src/components/social-login-buttons.tsx b/App/frontend/desktop/src/components/social-login-buttons.tsx new file mode 100644 index 000000000..078f7074f --- /dev/null +++ b/App/frontend/desktop/src/components/social-login-buttons.tsx @@ -0,0 +1,86 @@ +/** Google and GitHub sign-in actions shown only by international desktop packages. */ +import type { SocialLoginProvider } from "@memmy/local-api-contracts"; +import type { ReactNode } from "react"; +import { useTranslation } from "../i18n/use-translation.js"; + +// Keep the integration code available, but do not render social login until the +// production providers are ready to be exposed again. +const SOCIAL_LOGIN_BUTTONS_ENABLED = false; + +export interface SocialLoginButtonsProps { + pendingProvider: SocialLoginProvider | null; + disabled?: boolean; + feedback?: { text: string; tone: "error" | "success" } | null; + onLogin: (provider: SocialLoginProvider) => void; +} + +export function SocialLoginButtons(props: SocialLoginButtonsProps) { + const { t } = useTranslation(); + if (!SOCIAL_LOGIN_BUTTONS_ENABLED) return null; + + return ( +
+
+ } + disabled={props.disabled || Boolean(props.pendingProvider)} + onClick={() => props.onLogin("google")} + /> + } + disabled={props.disabled || Boolean(props.pendingProvider)} + onClick={() => props.onLogin("github")} + /> +
+ {props.feedback ? ( +

+ {props.feedback.text} +

+ ) : null} +
+ ); +} + +function SocialLoginButton(props: { + label: string; + icon: ReactNode; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function GoogleMark() { + return ( + + ); +} + +function GitHubMark() { + return ( + + ); +} diff --git a/App/frontend/desktop/src/components/tests/Modal.test.tsx b/App/frontend/desktop/src/components/tests/Modal.test.tsx index 3e21c083e..d06e9b4b8 100644 --- a/App/frontend/desktop/src/components/tests/Modal.test.tsx +++ b/App/frontend/desktop/src/components/tests/Modal.test.tsx @@ -1,8 +1,14 @@ +// @vitest-environment happy-dom + /** Modal tests. */ +import { act } from "react"; +import { createRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Modal, shouldCloseModalByBackdrop, shouldCloseModalByKey, shouldInitializeModalFocus } from "../modal.js"; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + describe("Modal", () => { it("renders a centered dialog without right-side drawer classes", () => { const html = renderToString( @@ -82,4 +88,46 @@ describe("Modal", () => { expect(shouldCloseModalByBackdrop({ target: backdrop, currentTarget: backdrop } as never)).toBe(true); expect(shouldCloseModalByBackdrop({ target: {}, currentTarget: backdrop } as never)).toBe(false); }); + + it("does not close when a text-selection drag starts inside the modal and ends on the backdrop", () => { + const container = document.createElement("div"); + const root = createRoot(container); + const onClose = vi.fn(); + document.body.append(container); + + try { + act(() => root.render( + + gpt-5.4 + + )); + + const backdrop = container.querySelector(".modal-backdrop")!; + const modelId = container.querySelector(".modal-body span")!; + + act(() => { + modelId.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + backdrop.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); + backdrop.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onClose).not.toHaveBeenCalled(); + + act(() => { + backdrop.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + modelId.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); + backdrop.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onClose).not.toHaveBeenCalled(); + + act(() => { + backdrop.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + backdrop.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); + backdrop.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onClose).toHaveBeenCalledOnce(); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); }); diff --git a/App/frontend/desktop/src/components/tests/social-login-buttons.test.tsx b/App/frontend/desktop/src/components/tests/social-login-buttons.test.tsx new file mode 100644 index 000000000..909a562a0 --- /dev/null +++ b/App/frontend/desktop/src/components/tests/social-login-buttons.test.tsx @@ -0,0 +1,20 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../../i18n/i18n-provider.js"; +import { SocialLoginButtons } from "../social-login-buttons.js"; + +describe("SocialLoginButtons", () => { + it("does not render social login choices while the feature is disabled", () => { + const onLogin = vi.fn(); + const html = renderToString( + + + + ); + + expect(html).toBe(""); + expect(html).not.toContain("Continue with Google"); + expect(html).not.toContain("Continue with GitHub"); + expect(onLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/App/frontend/desktop/src/components/tooltip.tsx b/App/frontend/desktop/src/components/tooltip.tsx index aebaaf97b..90b776aa2 100644 --- a/App/frontend/desktop/src/components/tooltip.tsx +++ b/App/frontend/desktop/src/components/tooltip.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, type FocusEvent, + type KeyboardEvent, type MouseEvent, type ReactElement, type ReactNode, @@ -13,12 +14,14 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; type TooltipPlacement = "top" | "bottom"; +type TooltipVariant = "default" | "description"; type TooltipTriggerProps = { "aria-describedby"?: string; onBlur?: (event: FocusEvent) => void; onClick?: (event: MouseEvent) => void; onFocus?: (event: FocusEvent) => void; + onKeyDown?: (event: KeyboardEvent) => void; onMouseEnter?: (event: MouseEvent) => void; onMouseLeave?: (event: MouseEvent) => void; ref?: Ref; @@ -31,7 +34,7 @@ let tooltipRoot: Root | null = null; let activeTrigger: HTMLElement | null = null; let listenersAttached = false; -export function Tooltip(props: { content: ReactNode; children: ReactElement }) { +export function Tooltip(props: { content: ReactNode; children: ReactElement; openOnClick?: boolean; variant?: TooltipVariant }) { const triggerRef = useRef(null); function show() { @@ -41,8 +44,8 @@ export function Tooltip(props: { content: ReactNode; children: ReactElement { + const trigger = triggerRef.current; return () => { - if (activeTrigger === triggerRef.current) { + if (activeTrigger === trigger) { hideActiveTooltip(); } }; @@ -67,7 +71,7 @@ export function Tooltip(props: { content: ReactNode; children: ReactElement { triggerRef.current = node; if (typeof childProps.ref === "function") { @@ -81,9 +85,22 @@ export function Tooltip(props: { content: ReactNode; children: ReactElement) => { - hideActiveTooltip(); + if (props.openOnClick) { + event.currentTarget.focus(); + show(); + } else { + hideActiveTooltip(); + } childProps.onClick?.(event); }, + onKeyDown: (event: KeyboardEvent) => { + if (props.openOnClick && event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + hide(); + } + childProps.onKeyDown?.(event); + }, onFocus: (event: FocusEvent) => { show(); childProps.onFocus?.(event); @@ -93,7 +110,7 @@ export function Tooltip(props: { content: ReactNode; children: ReactElement) => { - hide(); + if (!props.openOnClick || document.activeElement !== event.currentTarget) hide(); childProps.onMouseLeave?.(event); } }); @@ -105,6 +122,7 @@ function ensureTooltipElement(): HTMLSpanElement | null { } if (tooltipElement) { + if (!tooltipElement.isConnected) document.body.appendChild(tooltipElement); return tooltipElement; } @@ -119,19 +137,21 @@ function ensureTooltipElement(): HTMLSpanElement | null { return tooltipElement; } -function renderTooltip(content: ReactNode) { +function renderTooltip(content: ReactNode, variant: TooltipVariant = "default") { const element = ensureTooltipElement(); if (!element || !tooltipRoot) { return; } + element.classList.toggle("app-tooltip--description", variant === "description"); flushSync(() => { tooltipRoot?.render(<>{content}); }); element.classList.remove("app-tooltip--hidden", "app-tooltip--top", "app-tooltip--bottom"); + element.setAttribute("aria-hidden", "false"); } -function positionTooltip(trigger: HTMLElement) { +function positionTooltip(trigger: HTMLElement, variant: TooltipVariant = "default") { const element = ensureTooltipElement(); if (!element || typeof window === "undefined") { return; @@ -145,10 +165,12 @@ function positionTooltip(trigger: HTMLElement) { const tooltipHeight = tooltipRect.height; const topSpace = rect.top - viewportPadding; const bottomSpace = window.innerHeight - rect.bottom - viewportPadding; - const placement: TooltipPlacement = topSpace >= tooltipHeight + gap || topSpace >= bottomSpace ? "top" : "bottom"; + const placement: TooltipPlacement = variant === "description" && bottomSpace >= tooltipHeight + gap + ? "bottom" : topSpace >= tooltipHeight + gap || topSpace >= bottomSpace ? "top" : "bottom"; const centeredLeft = rect.left + rect.width / 2; + const preferredLeft = variant === "description" ? rect.left - 12 + tooltipWidth / 2 : centeredLeft; const left = Math.min( - Math.max(centeredLeft, viewportPadding + tooltipWidth / 2), + Math.max(preferredLeft, viewportPadding + tooltipWidth / 2), window.innerWidth - viewportPadding - tooltipWidth / 2 ); const top = placement === "top" @@ -165,6 +187,7 @@ function positionTooltip(trigger: HTMLElement) { function hideActiveTooltip() { activeTrigger = null; tooltipElement?.classList.add("app-tooltip--hidden"); + tooltipElement?.setAttribute("aria-hidden", "true"); detachGlobalListeners(); } @@ -176,6 +199,16 @@ function attachGlobalListeners() { listenersAttached = true; window.addEventListener("scroll", hideActiveTooltip, true); window.addEventListener("resize", hideActiveTooltip); + window.addEventListener("pointerdown", hideTooltipOnOutsidePress, true); + window.addEventListener("keydown", hideTooltipOnEscape); +} + +function hideTooltipOnOutsidePress(event: PointerEvent) { + if (event.target instanceof Node && !activeTrigger?.contains(event.target)) hideActiveTooltip(); +} + +function hideTooltipOnEscape(event: globalThis.KeyboardEvent) { + if (event.key === "Escape") hideActiveTooltip(); } function detachGlobalListeners() { @@ -186,4 +219,6 @@ function detachGlobalListeners() { listenersAttached = false; window.removeEventListener("scroll", hideActiveTooltip, true); window.removeEventListener("resize", hideActiveTooltip); + window.removeEventListener("pointerdown", hideTooltipOnOutsidePress, true); + window.removeEventListener("keydown", hideTooltipOnEscape); } diff --git a/App/frontend/desktop/src/components/use-social-login.ts b/App/frontend/desktop/src/components/use-social-login.ts new file mode 100644 index 000000000..eb71b179f --- /dev/null +++ b/App/frontend/desktop/src/components/use-social-login.ts @@ -0,0 +1,105 @@ +/** Browser-based Google and GitHub authentication for the international desktop package. */ +import type { AccountLoginResultView, SocialLoginProvider } from "@memmy/local-api-contracts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useApiClients } from "../app/providers.js"; +import { useTranslation } from "../i18n/use-translation.js"; +import { openExternalUrl } from "../utils/open-url.js"; +import type { AuthCodeFeedback } from "./use-verification-code-auth.js"; + +export interface UseSocialLoginResult { + pendingProvider: SocialLoginProvider | null; + feedback: AuthCodeFeedback | null; + start: (provider: SocialLoginProvider, invitationCode?: string) => Promise; + clearFeedback: () => void; + reset: () => void; +} + +export function useSocialLogin(): UseSocialLoginResult { + const { clients } = useApiClients(); + const { t, language } = useTranslation(); + const locale = useMemo<"zh" | "en">(() => (language === "zh-CN" ? "zh" : "en"), [language]); + const [pendingProvider, setPendingProvider] = useState(null); + const [feedback, setFeedback] = useState(null); + const interactionVersionRef = useRef(0); + + const reset = useCallback(() => { + interactionVersionRef.current += 1; + setPendingProvider(null); + setFeedback(null); + }, []); + + useEffect(() => reset, [reset]); + + const clearFeedback = useCallback(() => setFeedback(null), []); + + async function start( + provider: SocialLoginProvider, + rawInvitationCode?: string + ): Promise { + if (!clients || pendingProvider) return null; + const invitationCode = rawInvitationCode?.trim(); + const interactionVersion = interactionVersionRef.current + 1; + interactionVersionRef.current = interactionVersion; + setPendingProvider(provider); + setFeedback(null); + + try { + const flow = await clients.account.startSocialLogin({ + provider, + locale, + loginSource: "Memmy", + ...(invitationCode ? { invitationCode } : {}) + }); + if (!isCurrent(interactionVersion)) return null; + await openExternalUrl(flow.authorizationUrl); + setFeedback({ text: t("login.social.waiting"), tone: "success" }); + const expiresAt = Date.now() + flow.expiresInSec * 1000; + + while (isCurrent(interactionVersion) && Date.now() < expiresAt) { + await delay(flow.pollIntervalSec * 1000); + if (!isCurrent(interactionVersion)) return null; + const status = await clients.account.getSocialLoginStatus({ + flowId: flow.flowId, + pollToken: flow.pollToken + }); + if (status.status === "pending") continue; + if (status.status === "completed") { + setFeedback(null); + return status.result; + } + if (status.status === "expired") { + setFeedback({ text: t("login.social.expired"), tone: "error" }); + return null; + } + setFeedback({ text: status.message, tone: "error" }); + return null; + } + + if (isCurrent(interactionVersion)) { + setFeedback({ text: t("login.social.expired"), tone: "error" }); + } + return null; + } catch (error) { + if (isCurrent(interactionVersion)) { + setFeedback({ text: readableError(error, t("login.social.failed")), tone: "error" }); + } + return null; + } finally { + if (isCurrent(interactionVersion)) setPendingProvider(null); + } + } + + function isCurrent(version: number): boolean { + return interactionVersionRef.current === version; + } + + return { pendingProvider, feedback, start, clearFeedback, reset }; +} + +function readableError(error: unknown, fallback: string): string { + return error instanceof Error && error.message.trim() ? error.message.trim() : fallback; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/App/frontend/desktop/src/global.d.ts b/App/frontend/desktop/src/global.d.ts index 7272742ae..bc6a4c2b0 100644 --- a/App/frontend/desktop/src/global.d.ts +++ b/App/frontend/desktop/src/global.d.ts @@ -36,6 +36,9 @@ declare global { onUpdateDownloadProgress(callback: (progress: DesktopUpdateDownloadProgress) => void): () => void; openUpdateInstaller(filePath: string): Promise; openExternal(url: string): Promise; + getComputerHistoryPermissionSessionId?(): Promise; + restartForComputerHistoryPermissions?(): Promise; + openComputerHistoryMarkdown(filePath: string): Promise; openAgentTool(sourceId: string, prompt: string): Promise<{ opened: boolean }>; openMailto(mailtoUrl: string): Promise; copyImageToClipboard(request: DesktopImageActionRequest): Promise; diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index e8d1f6fb6..8496e2155 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -198,6 +198,12 @@ export const zhCNMessages = { "login.loginFailed": "登录失败,请检查验证码后重试", "login.submit": "完成注册", "login.continue": "登录 / 注册", + "login.social.google": "使用 Google 登录", + "login.social.github": "使用 GitHub 登录", + "login.social.opening": "正在打开…", + "login.social.waiting": "请在浏览器中完成登录…", + "login.social.expired": "登录已超时,请重试", + "login.social.failed": "第三方登录失败,请重试", "login.inviteLabel": "邀请码", "login.inviteOptionalTag": "(选填)", "login.invitePlaceholder": "邀请码(选填)", @@ -293,6 +299,8 @@ export const zhCNMessages = { "apiKey.provider.minimax": "MiniMax", "apiKey.provider.baidu": "百度文心", "apiKey.provider.doubao": "字节豆包", + "apiKey.provider.stepfun": "阶跃星辰", + "apiKey.provider.xiaomi": "小米 MiMo", "apiKey.provider.memmy": "Memmy", "apiKey.localEmbedding": "内嵌本地模型(默认 / 推荐)", "apiKey.customEmbedding": "使用自定义 API", @@ -872,6 +880,65 @@ export const zhCNMessages = { "memory.nav.insights": "洞察", "memory.nav.system": "系统", "memory.nav.overview": "概览", + "memory.nav.computerHistory": "电脑历史记录", + "computerHistory.history": "历史记录", + "computerHistory.recordDescription": "利用你的电脑历史记录增强记忆,让 Memmy 能帮助你处理正在做的任何事情。", + "computerHistory.enableTitle": "开启电脑历史记录?", + "computerHistory.enableConfirm": "确认开启", + "computerHistory.enableModelNotice": "开启后,{operations},会被记录并发送给已配置的大模型进行分析,", + "computerHistory.enableAllOperations": "你在电脑上的所有操作", + "computerHistory.enableLocalNotice": "使用记录保存在本机。", + "computerHistory.paused": "已暂停", + "computerHistory.info": "Memmy 会记录你电脑活动,并整理为文本摘要。你可以通过删除单条记录或清除历史记录来控制 Memmy 可以引用的内容。", + "computerHistory.startRecording": "开始记录", + "computerHistory.record": "开启电脑历史记录", + "computerHistory.permissions.title": "开启电脑历史记录", + "computerHistory.permissions.description": "请为 Memmy 开启以下两项权限。", + "computerHistory.permissions.macOnly": "电脑历史记录仅支持 macOS。", + "computerHistory.permissions.accessibility": "辅助功能", + "computerHistory.permissions.inputMonitoring": "输入监控", + "computerHistory.permissions.checking": "检测中", + "computerHistory.permissions.granted": "已开启", + "computerHistory.permissions.open": "去开启", + "computerHistory.permissions.ready": "权限已开启。退出并重新打开 Memmy 后,将自动开始记录。", + "computerHistory.permissions.checkFailed": "权限检测未完成:{error}", + "computerHistory.permissions.restart": "退出并重启", + "computerHistory.permissions.starting": "正在开启电脑历史记录…", + "computerHistory.permissions.retry": "重试", + "computerHistory.recording": "记录中", + "computerHistory.tokensExhausted": "Token 已用完", + "computerHistory.tokensExhaustedDescription": "暂时无法生成新的历史摘要,已有记录仍可查看。", + "computerHistory.pausedStop": "已暂停 · 停止", + "computerHistory.resume": "恢复记录", + "computerHistory.recordingFailed": "记录失败:{error}", + "computerHistory.recordingFailedUnknown": "记录意外停止,请重新开始记录。", + "computerHistory.narrationFailed": "摘要生成失败:{error}", + "computerHistory.clear": "清除历史", + "computerHistory.clearToday": "清除今天", + "computerHistory.clearAll": "清除全部", + "computerHistory.today": "今天", + "computerHistory.yesterday": "昨天", + "computerHistory.night": "凌晨", + "computerHistory.morning": "上午", + "computerHistory.afternoon": "下午", + "computerHistory.evening": "晚上", + "computerHistory.pin": "保留原始事件", + "computerHistory.unpin": "取消保留原始事件", + "computerHistory.pinLabel": "保留 {title}", + "computerHistory.unpinLabel": "取消保留 {title}", + "computerHistory.openMarkdown": "打开完整 Markdown", + "computerHistory.openMarkdownLabel": "打开 {title} 的完整 Markdown", + "computerHistory.openMarkdownUnavailable": "当前环境无法打开 Markdown 文件", + "computerHistory.openMarkdownFailed": "打开 Markdown 失败:{error}", + "computerHistory.delete": "删除", + "computerHistory.deleteAgain": "再点一次确认删除", + "computerHistory.deleteRecording": "正在记录这一段,停止后才能删除", + "computerHistory.deleteLabel": "删除 {title}", + "computerHistory.confirmDeleteLabel": "确认删除 {title}", + "computerHistory.confirm": "确认", + "computerHistory.emptyRecording": "正在记录,摘要写好后会出现在这里。", + "computerHistory.empty": "还没有记录,开启上方开关后开始记录。", + "computerHistory.workflow": "Workflow", "memory.nav.memory": "记忆", "memory.nav.userMemories": "用户记忆", "memory.nav.tasks": "任务", @@ -1831,6 +1898,12 @@ export const enUSMessages: Record = { "login.loginFailed": "Login failed, please check your code and try again", "login.submit": "Complete registration", "login.continue": "Log in / Sign up", + "login.social.google": "Continue with Google", + "login.social.github": "Continue with GitHub", + "login.social.opening": "Opening…", + "login.social.waiting": "Complete sign-in in your browser…", + "login.social.expired": "Sign-in timed out. Please try again.", + "login.social.failed": "Social sign-in failed. Please try again.", "login.inviteLabel": "Invite code", "login.inviteOptionalTag": "(optional)", "login.invitePlaceholder": "Invite code (optional)", @@ -1926,6 +1999,8 @@ export const enUSMessages: Record = { "apiKey.provider.minimax": "MiniMax", "apiKey.provider.baidu": "Baidu Wenxin", "apiKey.provider.doubao": "Doubao", + "apiKey.provider.stepfun": "StepFun", + "apiKey.provider.xiaomi": "Xiaomi MiMo", "apiKey.provider.memmy": "Memmy", "apiKey.localEmbedding": "Built-in local model (default / recommended)", "apiKey.customEmbedding": "Use a custom API", @@ -2504,6 +2579,65 @@ export const enUSMessages: Record = { "memory.nav.insights": "Insights", "memory.nav.system": "System", "memory.nav.overview": "Overview", + "memory.nav.computerHistory": "Computer History", + "computerHistory.history": "History", + "computerHistory.recordDescription": "Enhance memory with your computer history so Memmy can help with whatever you are working on.", + "computerHistory.enableTitle": "Enable computer history?", + "computerHistory.enableConfirm": "Enable recording", + "computerHistory.enableModelNotice": "Once enabled, {operations} will be recorded and sent to your configured language model for analysis. ", + "computerHistory.enableAllOperations": "all your activity on this computer", + "computerHistory.enableLocalNotice": "Activity records are stored on this computer.", + "computerHistory.paused": "Paused", + "computerHistory.info": "Memmy records your computer activity and organizes it into text summaries. You can control what Memmy can reference by deleting individual records or clearing your history.", + "computerHistory.startRecording": "Start recording", + "computerHistory.record": "Enable computer activity recording", + "computerHistory.permissions.title": "Enable Computer History", + "computerHistory.permissions.description": "Enable these two permissions for Memmy.", + "computerHistory.permissions.macOnly": "Computer History is only available on macOS.", + "computerHistory.permissions.accessibility": "Accessibility", + "computerHistory.permissions.inputMonitoring": "Input Monitoring", + "computerHistory.permissions.checking": "Checking", + "computerHistory.permissions.granted": "Enabled", + "computerHistory.permissions.open": "Enable", + "computerHistory.permissions.ready": "Permissions are enabled. Quit and reopen Memmy to start recording automatically.", + "computerHistory.permissions.checkFailed": "Could not check permissions: {error}", + "computerHistory.permissions.restart": "Quit & Reopen", + "computerHistory.permissions.starting": "Starting Computer History…", + "computerHistory.permissions.retry": "Retry", + "computerHistory.recording": "Recording", + "computerHistory.tokensExhausted": "Tokens exhausted", + "computerHistory.tokensExhaustedDescription": "New history summaries cannot be generated right now. You can still view existing records.", + "computerHistory.pausedStop": "Paused · Stop", + "computerHistory.resume": "Resume recording", + "computerHistory.recordingFailed": "Recording failed: {error}", + "computerHistory.recordingFailedUnknown": "Recording stopped unexpectedly. Start recording again.", + "computerHistory.narrationFailed": "Summary generation failed: {error}", + "computerHistory.clear": "Clear history", + "computerHistory.clearToday": "Clear today", + "computerHistory.clearAll": "Clear everything", + "computerHistory.today": "Today", + "computerHistory.yesterday": "Yesterday", + "computerHistory.night": "Night", + "computerHistory.morning": "Morning", + "computerHistory.afternoon": "Afternoon", + "computerHistory.evening": "Evening", + "computerHistory.pin": "Keep raw events", + "computerHistory.unpin": "Stop keeping the raw events", + "computerHistory.pinLabel": "Keep {title}", + "computerHistory.unpinLabel": "Stop keeping {title}", + "computerHistory.openMarkdown": "Open full Markdown", + "computerHistory.openMarkdownLabel": "Open the full Markdown for {title}", + "computerHistory.openMarkdownUnavailable": "Markdown files cannot be opened in this environment", + "computerHistory.openMarkdownFailed": "Could not open Markdown: {error}", + "computerHistory.delete": "Delete", + "computerHistory.deleteAgain": "Click again to confirm", + "computerHistory.deleteRecording": "This window is still being recorded; stop recording to delete it", + "computerHistory.deleteLabel": "Delete {title}", + "computerHistory.confirmDeleteLabel": "Confirm deleting {title}", + "computerHistory.confirm": "Confirm", + "computerHistory.emptyRecording": "Recording. Entries appear here once the summary is written.", + "computerHistory.empty": "Nothing recorded yet. Turn on the switch above to start recording.", + "computerHistory.workflow": "Workflow", "memory.nav.memory": "Memories", "memory.nav.userMemories": "User Memory", "memory.nav.tasks": "Tasks", diff --git a/App/frontend/desktop/src/main.tsx b/App/frontend/desktop/src/main.tsx index 2304580b2..9ca846d93 100644 --- a/App/frontend/desktop/src/main.tsx +++ b/App/frontend/desktop/src/main.tsx @@ -1,5 +1,5 @@ import { StrictMode, useState } from "react"; -import { createRoot } from "react-dom/client"; +import { createRoot, type Root } from "react-dom/client"; import { App } from "./app.js"; import { AppProviders } from "./app/providers.js"; import { initGtag } from "./analytics/gtag-init.js"; @@ -80,8 +80,14 @@ if (!root) { } const previewMode = readDevPreviewMode(); +const rendererWindow = window as Window & { __memmyReactRoot?: Root }; +const reactRoot = rendererWindow.__memmyReactRoot ?? createRoot(root); -createRoot(root).render( +if (import.meta.env.DEV) { + rendererWindow.__memmyReactRoot = reactRoot; +} + +reactRoot.render( {previewMode === "startup" ? ( diff --git a/App/frontend/desktop/src/pages/api-key-page.tsx b/App/frontend/desktop/src/pages/api-key-page.tsx index f3ddd6e7b..40f6fb6ed 100644 --- a/App/frontend/desktop/src/pages/api-key-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-page.tsx @@ -70,7 +70,9 @@ const providerOptions: ProviderOption[] = [ { value: "kimi", labelKey: "apiKey.provider.kimi", endpoint: "https://api.moonshot.ai/v1", defaultModelId: "moonshot-v1-128k" }, { value: "minimax", labelKey: "apiKey.provider.minimax", endpoint: "https://api.minimax.chat/v1", defaultModelId: "MiniMax-Text-01" }, { value: "baidu", labelKey: "apiKey.provider.baidu", endpoint: "https://qianfan.baidubce.com/v2", defaultModelId: "ernie-x1.1" }, - { value: "doubao", labelKey: "apiKey.provider.doubao", endpoint: "https://ark.cn-beijing.volces.com/api/v3", defaultModelId: "doubao-pro-256k" } + { value: "doubao", labelKey: "apiKey.provider.doubao", endpoint: "https://ark.cn-beijing.volces.com/api/v3", defaultModelId: "doubao-pro-256k" }, + { value: "stepfun", labelKey: "apiKey.provider.stepfun", endpoint: "https://api.stepfun.com/v1", defaultModelId: "step-3.5-flash" }, + { value: "xiaomi", labelKey: "apiKey.provider.xiaomi", endpoint: "https://api.xiaomimimo.com/v1", defaultModelId: "mimo-v2.5-pro" } ]; const defaultProvider = providerOptions[0]!; diff --git a/App/frontend/desktop/src/pages/app-frame.tsx b/App/frontend/desktop/src/pages/app-frame.tsx index 73b78608b..0265c4fbb 100644 --- a/App/frontend/desktop/src/pages/app-frame.tsx +++ b/App/frontend/desktop/src/pages/app-frame.tsx @@ -75,6 +75,7 @@ export interface AppFrameProps { reserveTopBar?: boolean; topBar?: ReactNode; topBarBorder?: boolean; + windowsTitlebarSafe?: boolean; /** When set, replaces the main app sidebar with settings section navigation. */ settingsNav?: SettingsSidebarNav; children: ReactNode; @@ -1569,7 +1570,7 @@ export function AppFrame(props: AppFrameProps) { onResizeBy={sidebarResize.resizeBy} /> -
+
{props.reserveTopBar !== false && (
{props.topBar} @@ -1580,7 +1581,7 @@ export function AppFrame(props: AppFrameProps) { className={`min-h-0 h-full flex-1 overflow-hidden${ sidebarHidden && !props.topBarBorder ? " app-frame-content-body--sidebar-hidden" : "" }`} - style={props.topBarBorder ? { paddingTop: "var(--codex-toolbar-height)" } : undefined} + style={props.topBarBorder ? { paddingTop: "calc(var(--codex-toolbar-height) + var(--app-frame-topbar-offset, 0px))" } : undefined} > {props.children}
diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index cbed71119..99b3d70fe 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -2657,6 +2657,7 @@ export function HomePage() { ) : null} topBarBorder={Boolean(hasActiveConversation || environmentScope)} + windowsTitlebarSafe={Boolean(hasActiveConversation || environmentScope)} >
{!hasActiveConversation ? ( diff --git a/App/frontend/desktop/src/pages/llm-provider-logo.tsx b/App/frontend/desktop/src/pages/llm-provider-logo.tsx index 5368d2fc7..e2283f736 100644 --- a/App/frontend/desktop/src/pages/llm-provider-logo.tsx +++ b/App/frontend/desktop/src/pages/llm-provider-logo.tsx @@ -8,6 +8,8 @@ import minimaxLogoUrl from "../assets/llm-provider-logo/minimax.svg"; import moonshotLogoUrl from "../assets/llm-provider-logo/moonshot.svg"; import openaiLogoUrl from "../assets/llm-provider-logo/openai.svg"; import qwenLogoUrl from "../assets/llm-provider-logo/qwen.svg"; +import stepfunLogoUrl from "../assets/llm-provider-logo/stepfun.svg"; +import xiaomiLogoUrl from "../assets/llm-provider-logo/xiaomi.svg"; import zhipuLogoUrl from "../assets/llm-provider-logo/zhipu.svg"; const LLM_PROVIDER_LOGOS: Readonly> = { @@ -23,6 +25,9 @@ const LLM_PROVIDER_LOGOS: Readonly> = { moonshot: moonshotLogoUrl, openai: openaiLogoUrl, qwen: qwenLogoUrl, + stepfun: stepfunLogoUrl, + xiaomi: xiaomiLogoUrl, + xiaomi_mimo: xiaomiLogoUrl, zhipu: zhipuLogoUrl }; diff --git a/App/frontend/desktop/src/pages/login-page.tsx b/App/frontend/desktop/src/pages/login-page.tsx index dd3e8338d..c20db5726 100644 --- a/App/frontend/desktop/src/pages/login-page.tsx +++ b/App/frontend/desktop/src/pages/login-page.tsx @@ -1,5 +1,5 @@ /** Login page module. */ -import type { OnboardingStateDto } from "@memmy/local-api-contracts"; +import type { AccountLoginResultView, OnboardingStateDto, SocialLoginProvider } from "@memmy/local-api-contracts"; import { useEffect, useState } from "react"; import { resolveDesktopAccountChannel } from "../app/account-channel.js"; import { buildInvitationSignupEvent } from "../app/invitation-analytics.js"; @@ -10,9 +10,11 @@ import { buildAccountOnboardingStartPatch, resolvePostLoginRoute, shouldShowFirs import { setAnalyticsUserId } from "../analytics/analytics-context.js"; import { useAnalytics } from "../analytics/use-analytics.js"; import { AuthCodeForm } from "../components/auth-code-form.js"; +import { SocialLoginButtons } from "../components/social-login-buttons.js"; import { LanguageToggleButton } from "../components/language-toggle-button.js"; import { Memmy } from "../components/mascot/memmy.js"; import { useVerificationCodeAuth } from "../components/use-verification-code-auth.js"; +import { useSocialLogin } from "../components/use-social-login.js"; import { getLegalLinkUrl } from "../legal/legal-links.js"; import { openExternalUrl } from "../utils/open-url.js"; import { useTranslation } from "../i18n/use-translation.js"; @@ -26,6 +28,7 @@ export function LoginPage() { const { track } = useAnalytics(); const { t, language } = useTranslation(); const verificationCodeAuth = useVerificationCodeAuth(); + const socialLogin = useSocialLogin(); const [identifier, setIdentifier] = useState(""); const [code, setCode] = useState(""); const [inviteCode, setInviteCode] = useState(""); @@ -43,18 +46,20 @@ export function LoginPage() { setModePersistenceFeedback(null); setPendingAccountOnboarding(null); verificationCodeAuth.resetInteractionState(); - }, [channel, verificationCodeAuth.resetInteractionState]); + socialLogin.reset(); + }, [channel, verificationCodeAuth.resetInteractionState, socialLogin.reset]); function toggleLanguage() { const nextLanguage = language === "en-US" ? "zh-CN" : "en-US"; verificationCodeAuth.clearFeedback(); + socialLogin.clearFeedback(); setModePersistenceFeedback(null); dispatch(appActions.settingsUpdated({ language: nextLanguage })); void clients?.config.updateSettings({ language: nextLanguage }).catch(() => undefined); } async function submitLogin() { - if (verificationCodeAuth.loginPending || modePersistencePending) { + if (verificationCodeAuth.loginPending || socialLogin.pendingProvider || modePersistencePending) { return; } setModePersistenceFeedback(null); @@ -70,6 +75,21 @@ export function LoginPage() { code, invitationEnabled ? inviteCode : undefined ); + await finishAccountLogin(loginResult, channel); + } + + async function startSocialLogin(provider: SocialLoginProvider) { + const loginResult = await socialLogin.start( + provider, + invitationEnabled ? inviteCode : undefined + ); + await finishAccountLogin(loginResult, provider); + } + + async function finishAccountLogin( + loginResult: AccountLoginResultView | null, + method: "email" | "phone" | SocialLoginProvider + ) { if (!loginResult || !loginResult.session.authenticated) { return; } @@ -82,7 +102,7 @@ export function LoginPage() { } track(buildInvitationSignupEvent({ - channel, + channel: method, isNewUser: session.isNewUser, invitationCode: invitationEnabled ? inviteCode : undefined })); @@ -157,8 +177,8 @@ export function LoginPage() { identifierType={channel} code={code} inviteCode={inviteCode} - disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || modePersistencePending} - sendCodeDisabled={verificationCodeAuth.sendCodeDisabled} + disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || Boolean(socialLogin.pendingProvider) || modePersistencePending} + sendCodeDisabled={verificationCodeAuth.sendCodeDisabled || Boolean(socialLogin.pendingProvider) || modePersistencePending} sendCodeLabel={verificationCodeAuth.sendCodeLabel} feedback={modePersistenceFeedback ?? verificationCodeAuth.feedback} onIdentifierChange={setIdentifier} @@ -169,6 +189,14 @@ export function LoginPage() { onOpenTerms={() => void openExternalUrl(getLegalLinkUrl("terms", language, state.bootstrap?.legal))} onOpenDataAgreement={() => void openExternalUrl(getLegalLinkUrl("data", language, state.bootstrap?.legal))} /> + {channel === "email" ? ( + void startSocialLogin(provider)} + /> + ) : null}
diff --git a/App/frontend/desktop/src/pages/memory-page.tsx b/App/frontend/desktop/src/pages/memory-page.tsx index bd6bbe777..fc70ab35a 100644 --- a/App/frontend/desktop/src/pages/memory-page.tsx +++ b/App/frontend/desktop/src/pages/memory-page.tsx @@ -1,7 +1,9 @@ +import type { TokenUsageDto } from "@memmy/local-api-contracts"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { buildMemorySubPageViewEvent } from "../analytics/page-view.js"; import { useAnalytics } from "../analytics/use-analytics.js"; import { useApiClients } from "../app/providers.js"; +import { isComputerHistorySupported } from "../app/computer-history-platform.js"; import { PRODUCT_TOUR_MEMORY_LOGS_NAV_ANCHOR, PRODUCT_TOUR_MEMORY_NAV_ANCHOR, @@ -15,6 +17,9 @@ import { useAppState } from "../state/app-state.js"; import { writeSettingsTabHash } from "./settings-nav.js"; import { SidebarResizeHandle, useCodexResizableSidebar } from "./sidebar-resize.js"; import { AnalyticsSubPage } from "./memory/analytics-sub-page.js"; +import { readHistoryPermissionSetup } from "./memory/computer-history-permission-state.js"; +import { isComputerHistoryQuotaExhausted, useComputerHistoryQuotaRefresh } from "./memory/computer-history-quota.js"; +import { ComputerHistorySubPage } from "./memory/computer-history-sub-page.js"; import { LogsSubPage } from "./memory/logs-sub-page.js"; import { resolveMemoryReferencePage, @@ -48,6 +53,7 @@ import { export type MemorySubPageId = | "overview" + | "computer-history" | "memories" | "user-memories" | "tasks" @@ -79,7 +85,8 @@ const memoryNavSections: MemoryNavSection[] = [ { id: "policies", labelKey: "memory.nav.policies", icon: }, { id: "world-model", labelKey: "memory.nav.worldModel", icon: }, { id: "skills", labelKey: "memory.nav.skills", icon: }, - { id: "user-memories", labelKey: "memory.nav.userMemories", icon: } + { id: "user-memories", labelKey: "memory.nav.userMemories", icon: }, + { id: "computer-history", labelKey: "memory.nav.computerHistory", icon: } ] }, { @@ -104,13 +111,25 @@ export interface MemoryPageProps { export function MemoryPage(props: MemoryPageProps) { const { clients } = useApiClients(); - const { dispatch } = useAppState(); + const { state, dispatch } = useAppState(); const { track, ready: analyticsReady } = useAnalytics(); const prevSubPageRef = useRef(null); const referenceRequestIdRef = useRef(0); - const [activePage, setActivePage] = useState(() => props.initialSubPage ?? readInitialMemorySubPage()); + const [selectedPage, setActivePage] = useState(() => props.initialSubPage ?? (isComputerHistorySupported() && readHistoryPermissionSetup() ? "computer-history" : readInitialMemorySubPage())); + // Stored selections, URL parameters and explicit navigation must all obey the + // same platform gate before mounting a page with polling/permission effects. + const activePage = supportedMemorySubPage(selectedPage); const [referenceRequest, setReferenceRequest] = useState<(MemoryReferenceOpenRequest & { page: MemoryReferencePage }) | null>(null); const client = clients?.memoryRuntime ?? null; + const historyQuotaExhausted = isComputerHistoryQuotaExhausted(state?.bootstrap); + const onHistoryQuotaUpdate = useCallback((usage: TokenUsageDto) => { + dispatch(appActions.tokenUsageUpdated(usage)); + }, [dispatch]); + useComputerHistoryQuotaRefresh({ + enabled: activePage === "computer-history" && state?.bootstrap?.app.userMode === "account", + client: clients?.config ?? null, + onUpdate: onHistoryQuotaUpdate, + }); const handleSubPageChange = useCallback((page: MemorySubPageId) => { setReferenceRequest(null); @@ -140,6 +159,7 @@ export function MemoryPage(props: MemoryPageProps) { const childByPage = useMemo>( () => ({ overview: , + "computer-history": , memories: ( , sources: }), - [client, dispatch, handleOpenMemoryReference, handleSubPageChange, referenceRequest] + [client, clients?.memmyAgent, dispatch, handleOpenMemoryReference, handleSubPageChange, referenceRequest, historyQuotaExhausted] ); useEffect(() => { @@ -219,6 +239,10 @@ function isMemorySubPageId(value: string | null): value is MemorySubPageId { return Boolean(value && memoryNavSections.some((section) => section.items.some((item) => item.id === value))); } +function supportedMemorySubPage(page: MemorySubPageId): MemorySubPageId { + return page === "computer-history" && !isComputerHistorySupported() ? "overview" : page; +} + export function readMemorySubPage(storage: Storage | undefined): MemorySubPageId | null { const value = storage?.getItem(MEMORY_SUB_PAGE_STORAGE_KEY) ?? null; return isMemorySubPageId(value) ? value : null; @@ -250,6 +274,7 @@ export interface MemoryPageViewProps { export function MemoryPageView(props: MemoryPageViewProps) { const { t } = useTranslation(); + const activePage = supportedMemorySubPage(props.activePage); const childByPage = props.childByPage ?? createPreviewChildByPage(t); const [sidebarHidden, setSidebarHidden] = useState(false); const sidebarResize = useCodexResizableSidebar("memmy.memory.sidebarWidth.codex.v2"); @@ -296,8 +321,8 @@ export function MemoryPageView(props: MemoryPageViewProps) { {t(section.titleKey)}