mirror of
https://github.com/aitingtingya/mv-obcc.git
synced 2026-07-22 07:47:28 +00:00
release: v0.5.0
This commit is contained in:
parent
bf9a37d633
commit
700b72e55c
24 changed files with 5670 additions and 133 deletions
38
.github/workflows/ci.yml
vendored
Normal file
38
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
node: [22]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
|
||||
package:
|
||||
needs: verify
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm run package
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mv-obcc-ide
|
||||
path: release/
|
||||
33
README.md
33
README.md
|
|
@ -3,7 +3,7 @@
|
|||
**mv-SenceAI** 是一款专为 Obsidian 打造的 AI 笔记、科研插件。它能够在您的本地代码环境与 Obsidian 知识库之间建立无缝的数据通道。
|
||||
|
||||
本插件包含三个相对独立的核心能力:
|
||||
1. **IDE 桥接 (IDE Bridge)**:为 Claude Code 提供 Obsidian 当前的上下文信息(如当前标签、选区内容),并支持通过标准 MCP 协议调用特定工具以及审核代码差异(Diff)。
|
||||
1. **IDE 桥接 (IDE Bridge)**:为 Claude Code 与 Codex CLI 提供 Obsidian 当前的上下文信息(如当前标签、选区内容)。Claude Code 侧支持标准 MCP 主动工具和差异审核(Diff);Codex CLI 侧支持 `/ide` 上下文读取,并通过标准 MCP 使用 Obsidian 工具。
|
||||
2. **划词助手 (LLM Assistant)**:完全独立于 IDE 桥接的内置功能。允许您在 Obsidian 的各种视图(Markdown、PDF、Web Viewer)中选中文本后,通过自定义提示词直接流式调用 OpenAI 或 Anthropic 兼容的语言模型 API。
|
||||
3. **行内补全 (Inline Completion)**:在 Markdown 编辑器中显示 ghost text 续写建议,可通过左侧功能区按钮按需点亮,并支持接受、取消、拒绝后重新生成。
|
||||
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
### 方法一:插件已经上架obsidian官方插件社区,搜索mv-SenceAI 即可找到并安装
|
||||
### 方法二:手动从 Release 安装
|
||||
|
||||
1. 前往 GitHub 仓库的 [Releases](https://github.com/aitingtingya/mv-senceai/releases) 页面,下载最新版本(如 `0.4.0`)的以下三个资产文件:
|
||||
1. 前往 GitHub 仓库的 [Releases](https://github.com/aitingtingya/mv-senceai/releases) 页面,下载最新版本(如 `0.4.5`)的以下三个资产文件:
|
||||
- `main.js`
|
||||
- `manifest.json`
|
||||
- `styles.css`
|
||||
|
|
@ -51,6 +51,13 @@
|
|||
- 在 Claude Code 终端输入 `/ide`,应提示已连接到 Obsidian。
|
||||
- 输入 `claude mcp list`,应当能看到 `mv-senceai-ide`(或其提供的工具)已连接。
|
||||
|
||||
### 4. Codex CLI 侧配置
|
||||
1. 确保本机已安装并登录 Codex CLI。
|
||||
2. 在插件设置底部启用 `启用 Codex IDE 功能`。
|
||||
3. 在与该 Obsidian Vault 对应的本地目录中启动 Codex CLI。
|
||||
4. 在 Codex CLI 中输入 `/ide`,应提示已连接,并在后续消息中自动带入当前 Obsidian 标签、选区和打开标签上下文。
|
||||
5. 输入 `/mcp`,应能看到本插件的 MCP 工具;这些工具包括读取最近选区、列出打开标签、打开文件和读取 Web Viewer 页面。
|
||||
|
||||
---
|
||||
|
||||
## 使用指南
|
||||
|
|
@ -59,12 +66,13 @@
|
|||
|
||||
- **被动状态感知**:开启后,您可以选择让插件追踪 Markdown、PDF 或是 Web Viewer。插件会将当前的标签页和选区状态被动同步给 Claude Code。
|
||||
- *多实例支持*:开启“支持所有活动页面”后,插件会精确绑定 Claude PID 和会话。当同时运行多个 Claude 时,每个会话只会隐藏自己的终端,依然可以读取其他终端的信息。
|
||||
- **主动工具 (MCP)**:Claude Code 可以通过 HTTP MCP 协议主动调用插件提供的工具,例如:
|
||||
- **主动工具 (MCP)**:Claude Code 与 Codex CLI 可以通过 HTTP MCP 协议主动调用插件提供的工具,例如:
|
||||
- `getLatestSelection`:读取最后一次非空选区。
|
||||
- `getOpenEditors`:获取所有已打开的标签列表。
|
||||
- `openFile`:在 Obsidian 中定位并打开特定的 Vault 文件。
|
||||
- `readCurrentWebPage`:无需刷新或跳转,直接将 Obsidian Web Viewer 中正在浏览的网页读取为 Markdown 格式。
|
||||
- **差异可视化审核 (Diff)**:当 Claude 提议修改文件时,如果是需要授权的操作,插件会在 Obsidian 侧弹出基于 CodeMirror MergeView 的差异比对界面。您可以在界面内进行编辑和最终确认,确认后的内容会由 Claude 写入硬盘。
|
||||
- **Codex CLI 支持**:Codex 集成使用 CLI `/ide` 的本地 IPC 上下文协议,并把插件的 MCP HTTP 服务写入 Codex 配置。连接或配置失败不会阻塞插件启动,也不会影响 Claude、划词助手或行内补全。
|
||||
|
||||
### ✍️ 划词助手功能
|
||||
|
||||
|
|
@ -118,9 +126,9 @@
|
|||
- **视觉隔离策略**:
|
||||
“切换标签时保留选区高亮”功能仅为视觉辅助,在您切换到终端等标签时,原页面的选词高亮依然保留。这不影响内部发送给 Claude 的实际内容。
|
||||
- **配置的隔离性**:
|
||||
划词助手的网络错误、API Key 暴露或调用失败,均只影响划词助手自身,**绝对不会**波及或影响 Claude Code 桥接通道的稳定性。
|
||||
划词助手的网络错误、API Key 暴露或调用失败,均只影响划词助手自身,**绝对不会**波及或影响 Claude Code / Codex CLI 桥接通道的稳定性。
|
||||
- **桌面权限说明**:
|
||||
插件会读取和更新 Claude Code 的项目配置、会话信息与 IDE lock 文件,以建立和恢复桥接连接。进程识别与 MCP 注册仅通过 Node.js `execFile` 调用明确的可执行文件和参数,不拼接或执行任意 shell 命令。
|
||||
Claude Code 集成会读取和更新 Claude Code 的项目配置、会话信息与 IDE lock 文件,以建立和恢复桥接连接。Codex CLI 集成会创建本地 `/ide` IPC socket/pipe,并在 `~/.codex/config.toml` 中维护一个指向本插件 `/mcp` 服务的受管理配置块;不会启动 `codex app-server` 子进程。
|
||||
|
||||
## 鸣谢
|
||||
|
||||
|
|
@ -130,10 +138,10 @@
|
|||
|
||||
# mv-SenceAI (English Documentation)
|
||||
|
||||
**mv-SenceAI** is a desktop bridge connecting your local vaults to Claude Code. It runs passively in the background to streamline your developer workflow in Obsidian.
|
||||
**mv-SenceAI** is a desktop bridge connecting your local vaults to Claude Code and Codex CLI. It runs passively in the background to streamline your developer workflow in Obsidian.
|
||||
|
||||
This plugin provides three key capabilities:
|
||||
1. **IDE Bridge**: Feeds contextual information (active tab, selections) from your vault to Claude Code, supports MCP tools, and enables visual diff reviews (via CodeMirror MergeView).
|
||||
1. **IDE Bridge**: Feeds contextual information (active tab, selections) from your vault to Claude Code and Codex CLI. Claude Code uses the existing IDE/MCP bridge; Codex CLI uses `/ide` context IPC plus standard MCP tools.
|
||||
2. **LLM Assistant (Selection Reader)**: A completely independent feature to call OpenAI or Anthropic compatible APIs directly from Obsidian views (Markdown, PDF, Web Viewer) using custom prompt templates, streaming responses into a floating output window.
|
||||
3. **Inline Completion**: A separate Markdown-only ghost-text completion module with a ribbon toggle and configurable accept, cancel, and reject/regenerate keys.
|
||||
|
||||
|
|
@ -182,8 +190,15 @@ If you prefer to clone and compile the source code yourself:
|
|||
>
|
||||
> - **Web Reader Limits**: Standard iframe and canvas reading limits apply.
|
||||
> - **PDF Reading**: Scanned PDF pages without text layers cannot be read.
|
||||
> - **Config Isolation**: Errors or issues with the LLM selection assistant will not affect the main Claude Code IDE bridge.
|
||||
> - **Desktop Permissions**: The plugin reads and updates Claude Code project settings, session metadata, and IDE lock files to manage the bridge. Process detection and MCP registration use Node.js `execFile` with explicit executables and arguments; the plugin does not construct or execute arbitrary shell commands.
|
||||
> - **Config Isolation**: Errors or issues with the LLM selection assistant will not affect the Claude Code or Codex CLI IDE bridges.
|
||||
> - **Desktop Permissions**: The Claude Code bridge reads and updates Claude Code project settings, session metadata, and IDE lock files. The Codex bridge creates a local `/ide` IPC socket/pipe and maintains a managed MCP server block in `~/.codex/config.toml`; it does not start `codex app-server`.
|
||||
|
||||
### Codex CLI Bridge
|
||||
|
||||
- Enable **Codex IDE** at the bottom of the plugin settings.
|
||||
- Start Codex CLI in the matching vault directory and run `/ide`; Codex should connect to Obsidian context.
|
||||
- Run `/mcp` in Codex CLI to confirm the mv-SenceAI MCP tools are visible.
|
||||
- Codex receives active file, selection, cursor, and open-tab context through `/ide`; it uses Obsidian actions through standard MCP.
|
||||
|
||||
### LLM Assistant Interaction
|
||||
|
||||
|
|
|
|||
102
TEST-REPORT.md
Normal file
102
TEST-REPORT.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# MV OBCC IDE 0.2.7 测试报告
|
||||
|
||||
日期:2026-06-13
|
||||
|
||||
## 环境
|
||||
|
||||
- macOS 26.5.1,Apple Silicon
|
||||
- Obsidian 1.12.7
|
||||
- Claude Code 2.1.174
|
||||
- Node.js 22.18.0
|
||||
- Mimo Anthropic 兼容上游
|
||||
|
||||
## 自动验证
|
||||
|
||||
- TypeScript strict 类型检查:通过
|
||||
- Vitest:65/65 通过
|
||||
- 生产 esbuild:通过
|
||||
- `npm audit`:0 个漏洞
|
||||
- 从源码 ZIP 全新执行 `npm ci` 和 `npm run verify`:通过
|
||||
- 安装 ZIP、源码 ZIP 和插件文件 SHA-256 校验:通过
|
||||
- GitHub Actions 同时配置 `macos-latest` 和 `windows-latest`
|
||||
|
||||
测试覆盖消息角色移动、上游地址优先级、代理循环规避、旧配置迁移、MCP 鉴权和初始化、Windows `.cmd`/`.bat`/`.exe` 参数传递、MCP 注册复验、macOS CLI 调用回归、WebSocket IDE 鉴权、定向客户端消息、端口冲突、macOS/Windows PID 解析、Claude session 文件校验、Hook 合并恢复、活动页面类型、终端标记、路径、lock 文件、Claude 设置恢复、Diff 状态幂等、网页精确标签读取、跨平台最近选区缓存和持久视觉高亮状态机。
|
||||
|
||||
## macOS 真实联调
|
||||
|
||||
- 插件加载、稳定端口和 IDE lock:通过
|
||||
- `/health` 与 `/healthz` 健康检查:通过
|
||||
- `/ide` 连接与 `getDiagnostics` 内部调用:通过
|
||||
- HTTP MCP 自动注册和 `claude mcp list` Connected:通过
|
||||
- MCP 关闭后移除注册且端点返回 404,重新开启后自动恢复:通过
|
||||
- MCP 无令牌返回 401,正确令牌初始化返回 200:通过
|
||||
- 真实模型调用 `mcp__mv-obcc-ide__getCurrentSelection`:通过
|
||||
- 主动工具开关同时影响 `tools/list` 和直接调用:通过
|
||||
- 全部打开标签包含 Markdown、PDF、网页、终端和插件页:通过
|
||||
- 图片标签标题、类型和绝对路径:通过
|
||||
- 后台 DeferredView 文件路径恢复:通过
|
||||
- Obsidian Web Viewer 选区:通过
|
||||
- 切换到无选区标签后保留最后一次非空网页选区:通过
|
||||
- GitHub 当前页面使用 Obsidian Reader 转 Markdown:通过
|
||||
- 网页读取前后 URL 与 WebContents ID 完全不变:通过
|
||||
- PDF 文件、页码和同一 DOM 文本选区:通过
|
||||
- 中文设置页面和自动上游来源显示:通过
|
||||
- 兼容转换抓包:正文出现一次、只保留 user 消息:通过
|
||||
- Diff 拒绝:源文件保持原文,通过
|
||||
- Diff 接受:采用右侧最后编辑的全文并只写入一次,通过
|
||||
- Claude 内部 `close_tab` 生命周期:通过
|
||||
- `acceptEdits`:直接写入且不创建 Diff 标签,通过
|
||||
- 两个 Claude 进程同时连接同一 IDE 端口:通过
|
||||
- 两个进程分别拥有独立 PID、TTY 和 Claude session 文件:通过
|
||||
- Terminal 3.26.0 支持 OSC 标题更新:通过
|
||||
|
||||
## 0.2.3 变更验证
|
||||
|
||||
- Windows MCP 注册不再使用 `shell: true`。
|
||||
- `.cmd` 和 `.bat` 通过显式 `cmd.exe /d /s /c` 调用,HTTP Authorization header 保持为单一参数。
|
||||
- `.exe` 继续通过参数数组直接调用。
|
||||
- 注册成功必须经过 `claude mcp get mv-obcc-ide` 对 URL、令牌和 Connected 状态的复验。
|
||||
- macOS 保持直接 `execFile(executable, args)`;对应回归测试通过。
|
||||
- Windows 实机操作由用户按验证手册完成,本报告不声称 Windows 实机通过。
|
||||
|
||||
## 0.2.4 变更验证
|
||||
|
||||
- 网页工具只读取精确保存的 `WorkspaceLeaf`;相同 URL 或标题的其他标签不会被误读。
|
||||
- 网页正文来自当前 WebContents 的 `document.body.innerText`,不再调用 Reader,因此隐藏的预载任务内容不会混入结果。
|
||||
- 自动测试覆盖视口上方、视口内和视口下方的已加载正文,确认返回整个滚动文档而非当前视口。
|
||||
- 网页默认取消原有字符上限;超过 20 万字符的测试正文完整返回。只有设置正整数上限时才截断。
|
||||
- Windows HTTP MCP 没有可靠 `sessionId` 时,`getLatestSelection` 回退到全局最近非空选区;空选区不会覆盖缓存。
|
||||
- 有 `sessionId` 的 macOS/IDE 客户端继续严格按会话隔离,不回退到其他会话。
|
||||
- 本节为代码和自动化验证,不声称完成新的 Windows 或 macOS 真机交互测试。
|
||||
|
||||
## 0.2.5 变更验证
|
||||
|
||||
- Markdown 使用独立 CodeMirror 状态字段保存视觉范围;失焦产生的空选区不会清除,源编辑器内的空点、键盘折叠或输入会清除。
|
||||
- Markdown 视觉范围会随文档修改映射,被选文字完全删除后自动移除。
|
||||
- PDF 只在 Selection 的锚点仍位于当前 PDF 视图时处理空选区,切换到终端或其他标签产生的空 Selection 会被忽略。
|
||||
- Web Viewer 页面内部只在真实非空选区时更新高亮;空的 `selectionchange` 保留高亮,页面内 `pointerup` 或 `keyup` 后的空选区才清除。
|
||||
- 视觉高亮拥有独立设置和存储路径,不读写 `SelectionState`、最近选区缓存、IDE WebSocket 或 MCP。
|
||||
- PDF 和 Web Viewer 使用 CSS Custom Highlight 并按能力静默降级;Markdown 不依赖该 API。
|
||||
- 本节完成类型检查、自动测试和生产构建,不声称完成新的 Windows 或 macOS 真机视觉验收。
|
||||
|
||||
## 0.2.6 变更验证
|
||||
|
||||
- Markdown 备用 Decoration 在真实 CodeMirror 选区仍非空时也会生成,修复切换终端后原生选区不再着色、备用层又被抑制的问题。
|
||||
- 编辑器聚焦时通过 CSS 隐藏备用层,由 Obsidian 原生选区显示;编辑器失焦时备用层恢复主题的 `--text-selection` 颜色,避免双重着色。
|
||||
- 自动测试直接检查真实非空选区下的 Decoration 范围,并验证聚焦与失焦 CSS 规则。
|
||||
- PDF、Web Viewer、`SelectionState`、最近选区缓存、IDE WebSocket 和 MCP 代码未修改。
|
||||
- 本节完成类型检查、自动测试和生产构建,不声称完成新的 macOS 真机视觉验收。
|
||||
|
||||
## 0.2.7 变更验证
|
||||
|
||||
- 修复 Markdown ViewPlugin 在 CodeMirror `update()` 内嵌套 `dispatch()` 导致插件实例崩溃、保存范围从未建立的问题。
|
||||
- 保存范围改为在原始 transaction 内由 StateField 同步更新,不创建第二个 transaction,也不修改真实 selection。
|
||||
- 高亮改用 `outerDecorations`,尽量包裹 Live Preview 的普通 Markdown 渲染 Decoration。
|
||||
- 编辑器内使用 `:focus-within` 隐藏备用颜色;焦点进入终端或其他标签时恢复主题的 `--text-selection`。
|
||||
- jsdom 真实 EditorView 测试验证:非空选择产生实际高亮 DOM、CodeMirror exception sink 无异常、外部元素取得焦点后真实 selection 保持不变。
|
||||
- 开关关闭后停止记录并清除范围;重新开启时从当前真实 selection 恢复。PDF、Web、IDE、MCP 和最近选区逻辑未修改。
|
||||
- 本节完成类型检查、自动测试和生产构建;最终视觉效果仍由 Obsidian 真机验收。
|
||||
|
||||
## Windows 状态
|
||||
|
||||
代码包含 Windows 路径处理、Claude 可执行文件候选和 Windows CI;当前机器没有 Windows 虚拟机,因此没有声称 Windows 实机通过。请按 [`WINDOWS-VALIDATION.md`](WINDOWS-VALIDATION.md) 在 Windows Codex 环境继续验证。
|
||||
119
WINDOWS-VALIDATION.md
Normal file
119
WINDOWS-VALIDATION.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Windows 实机验证手册
|
||||
|
||||
在真实 Windows 10/11 机器完成本清单后,才能把 Windows 状态标记为“实机通过”。
|
||||
|
||||
## 环境记录
|
||||
|
||||
记录以下版本与路径:
|
||||
|
||||
- Windows 版本和内部版本号
|
||||
- Obsidian 桌面版版本
|
||||
- Claude Code 版本
|
||||
- Node.js 版本
|
||||
- 测试 vault 路径,必须同时包含空格和中文,例如
|
||||
`C:\Users\<name>\Documents\Test Vault 中文`
|
||||
|
||||
## 构建与安装
|
||||
|
||||
1. 在源码目录运行 `npm ci`。
|
||||
2. 运行 `npm run verify`,确认类型检查、65 项测试及生产构建全部通过。
|
||||
3. 运行 `npm run package`。
|
||||
4. 在 `release` 目录运行以下命令,并与 `SHA256SUMS` 逐项比较:
|
||||
|
||||
```powershell
|
||||
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7.zip
|
||||
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7-source.zip
|
||||
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7\main.js
|
||||
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7\manifest.json
|
||||
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7\styles.css
|
||||
```
|
||||
|
||||
5. 将 `release\mv-obcc-ide-0.2.7\` 复制到 `<vault>\.obsidian\plugins\mv-obcc-ide\`。
|
||||
6. 只启用 `MV OBCC IDE`,关闭其他 Claude Code IDE 桥接插件。
|
||||
7. 检查 `%USERPROFILE%\.claude\ide\` 中生成了对应端口的 lock 文件。
|
||||
|
||||
## MCP 与设置
|
||||
|
||||
1. 打开插件设置,确认全部文案为中文。
|
||||
2. 确认 Claude 可执行文件能自动识别;若失败,填写 `claude.cmd` 或 `claude.exe` 完整路径。
|
||||
3. 点击“重新注册”,确认状态显示“MCP 已注册并验证;重新启动 Claude Code 后生效”。
|
||||
4. 运行 `claude mcp list` 和 `claude mcp get mv-obcc-ide`,确认 URL、Authorization header 和 Connected 状态正确。
|
||||
5. 重启 Claude Code,确认出现 `mcp__mv-obcc-ide__getLatestSelection`、`getOpenEditors`、`openFile` 和已启用的网页工具。
|
||||
6. 逐一关闭和开启主动工具,重新启动 Claude Code,确认工具清单同步变化。
|
||||
7. 关闭“启用 MCP 主动工具”,确认 local MCP 注册被删除;重新开启后确认恢复。
|
||||
8. 检查 MCP 只监听 `127.0.0.1`,且无 Bearer token 时返回 401。
|
||||
|
||||
## 上游自动解析
|
||||
|
||||
1. 将插件中的手动上游地址留空。
|
||||
2. 分别在用户、项目和 local Claude 设置中配置不同 URL,确认显示来源遵循 local > project > user。
|
||||
3. 兼容模式下确认 `.claude\settings.local.json` 指向插件本地端口。
|
||||
4. 确认插件不会把本地代理地址当成真实上游形成循环。
|
||||
5. 用脱敏抓包验证选区正文只在 `user` 消息出现一次。
|
||||
6. 切换回原生模式,确认插件只恢复自己接管过的值。
|
||||
|
||||
## 标签与选区
|
||||
|
||||
1. 保持“支持所有活动页面”关闭,分别验证 Markdown、PDF、Web Viewer 三个开关。
|
||||
2. 开启“支持所有活动页面”,确认三个页面类型开关变为不可用。
|
||||
3. 检查 `.claude\settings.local.json` 中只新增本插件标记的 `SessionStart` 和 `UserPromptSubmit` Hook。
|
||||
4. 同时从两个终端启动 Claude A 和 Claude B。
|
||||
5. 确认 `%USERPROFILE%\.claude\sessions\<PID>.json` 分别映射两个 session。
|
||||
6. 在 A 中交互时确认 A 保留上一个非自身终端页面;切到 B 的终端后,A 可以读取 B。
|
||||
7. 对 B 执行对称测试;退出后确认其他 Claude 设置和 Hook 没有被破坏。
|
||||
|
||||
随后继续通用标签测试:
|
||||
|
||||
1. 打开 Markdown、PDF、PNG/JPG、Web Viewer、终端、搜索和一个第三方插件标签。
|
||||
2. 调用 `getOpenEditors`,确认所有标题、类型和活动状态正确。
|
||||
3. 确认后台延迟加载的 Markdown、PDF 和图片仍返回 Windows 绝对路径。
|
||||
4. 在 Markdown 中选择中文、公式、Unicode 和 CRLF 多行文本。
|
||||
5. 在 PDF 文本层选择文字,确认文件、页码和正文正确。
|
||||
6. 在 Web Viewer 中选择文字,确认 URL、标题和正文正确。
|
||||
7. 选中一段文本并发送任意消息,等 Claude 回复结束、右下角不再显示选区状态后,再调用 `getLatestSelection`,确认仍返回刚才的最后一次非空选区。
|
||||
8. 清空选区并继续聊天,再次调用 `getLatestSelection`,确认空选区没有覆盖上一步结果。
|
||||
|
||||
## 视觉高亮
|
||||
|
||||
1. 确认“切换标签时保留选区高亮”默认开启。
|
||||
2. 分别在 Markdown 源码模式、Live Preview、PDF 和 Web Viewer 中划词,然后切到普通标签和终端插件标签,确认源页面高亮仍保留。
|
||||
3. 回到源页面空点,确认只清除该标签的视觉高亮;重新划词时确认替换该标签原高亮。
|
||||
4. 在多个标签中分别划词,确认每个标签最多保留一个且互不影响。
|
||||
5. Markdown 中编辑选中文字前后的内容,确认范围随编辑移动;完全删除选中文字后高亮消失。
|
||||
6. 刷新或导航 Web Viewer、滚动触发 PDF 文本层重建,确认失效高亮安全清理且插件无报错。
|
||||
7. 关闭设置开关,确认全部视觉高亮立即清除;重新开启后无需重启。
|
||||
8. 重复 `getLatestSelection` 和状态感知测试,确认视觉高亮不改变发送给 Claude 的正文。
|
||||
|
||||
## 网页转 Markdown
|
||||
|
||||
1. 打开普通文章、GitHub 页面和需要登录的页面。
|
||||
2. 记录读取前的 URL、标题和页面状态。
|
||||
3. 调用 `readCurrentWebPage`,确认返回的是当前这个精确标签的 Markdown。
|
||||
4. 确认页面没有刷新、没有跳转、登录状态未丢失。
|
||||
5. 在长页面的顶部调用工具,确认结果同时包含视口下方已经加载但尚未滚动到的正文。
|
||||
6. 打开两个 URL 或标题相同的标签,内容保持不同;切换到其中一个再离开网页,确认工具只读取最后追踪的精确标签。
|
||||
7. 测试 SPA 动态内容,确认读取当前可见 DOM,不包含 `display: none` 的预载或 Reader 隐藏内容。
|
||||
8. 测试 Canvas、跨域 iframe 和图片页面,确认返回明确的不可提取或部分提取状态。
|
||||
9. 将“网页工具最大返回字符数”留空或设为 `0`,确认不截断;填写正整数后确认 `truncated` 标记和长度正确。
|
||||
|
||||
## 文件导航与 Diff
|
||||
|
||||
1. 用 `openFile` 测试含空格和中文的 Windows 路径。
|
||||
2. 测试指定行、文本范围、仓库外路径拒绝和不存在文件。
|
||||
3. 启动不预批准 `Edit` 的 Claude Code,修改现有文件。
|
||||
4. 确认 Obsidian 出现可编辑 Diff;拒绝后文件不变。
|
||||
5. 再次修改,在右侧更改候选文本后接受,确认只写入最终文本一次。
|
||||
6. 关闭 Diff 标签,确认 Claude 收到 `DIFF_REJECTED`。
|
||||
7. 并发打开两个 Diff 并逆序处理。
|
||||
8. 测试新文件和源文件审核期间被外部修改的冲突。
|
||||
9. 使用 `acceptEdits` 重试,确认直接写入且不额外弹 Diff。
|
||||
|
||||
## 生命周期
|
||||
|
||||
1. 保持 Obsidian 开启并重启 Claude Code。
|
||||
2. 保持 Claude Code 关闭并重启 Obsidian。
|
||||
3. 制造首选端口冲突,确认 lock、MCP URL 和实际端口一致。
|
||||
4. 禁用插件,确认服务和 lock 消失,未完成 Diff 被拒绝。
|
||||
5. 重新启用插件,确认 MCP 自动修复注册。
|
||||
|
||||
每项记录通过/失败、截图、脱敏日志和复现步骤。Windows 实机完成前不要修改测试报告中的未验证声明。
|
||||
246
main.ts
246
main.ts
|
|
@ -1,6 +1,6 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import {
|
||||
MarkdownView,
|
||||
Notice,
|
||||
Plugin,
|
||||
type WorkspaceLeaf,
|
||||
|
|
@ -60,11 +60,23 @@ import {
|
|||
import {
|
||||
activeWorkspaceLeaf,
|
||||
currentWorkspaceContext,
|
||||
getOpenWorkspaceTabs,
|
||||
} from "./src/workspace-context";
|
||||
import { SelectionHighlightController } from "./src/selection-highlights";
|
||||
import { TerminalSessionTracker } from "./src/terminal-session-tracker";
|
||||
import { LlmFeature } from "./src/llm-feature";
|
||||
import { InlineCompletionFeature } from "./src/inline-completion/inline-completion-feature";
|
||||
import {
|
||||
CodexIdeProvider,
|
||||
type CodexIdeContextSnapshot,
|
||||
} from "./src/codex-ide-provider";
|
||||
import {
|
||||
ensureCodexMcpRegistration,
|
||||
removeCodexMcpRegistration,
|
||||
ensureCodexShellAlias,
|
||||
removeCodexShellAlias,
|
||||
} from "./src/codex-mcp-registration";
|
||||
import { syncAgentRulesInWorkspace } from "./src/agent-rules-manager";
|
||||
import type {
|
||||
BridgeClientContext,
|
||||
BridgeSettings,
|
||||
|
|
@ -78,7 +90,12 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
settings: BridgeSettings = { ...DEFAULT_SETTINGS };
|
||||
port = 0;
|
||||
mcpStatus = "尚未检查";
|
||||
codexMcpStatus = "Codex MCP 未启用";
|
||||
claudeIdeError: string | null = null;
|
||||
codexIdeError: string | null = null;
|
||||
private server: BridgeServer | null = null;
|
||||
private bridgeAuthToken: string | null = null;
|
||||
private bridgeHasClaudeLock = false;
|
||||
private readonly latestSelections = new Map<string, SelectionState>();
|
||||
private latestWebLeaf: WorkspaceLeaf | null = null;
|
||||
private readonly lastContexts = new Map<string, SelectionState>();
|
||||
|
|
@ -90,30 +107,40 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
private selectionHighlighter: SelectionHighlightController | null = null;
|
||||
private llmFeature: LlmFeature | null = null;
|
||||
private inlineCompletion: InlineCompletionFeature | null = null;
|
||||
private codexIdeProvider: CodexIdeProvider | null = null;
|
||||
private mcpRegistrationTimer: number | null = null;
|
||||
private mcpRegistrationInFlight: Promise<void> | null = null;
|
||||
private codexMcpRegistrationTimer: number | null = null;
|
||||
private codexMcpRegistrationInFlight: Promise<void> | null = null;
|
||||
private unloaded = false;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
this.unloaded = false;
|
||||
const loaded = (await this.loadData()) as Partial<BridgeSettings> | null;
|
||||
const rawLoaded = (await this.loadData()) as
|
||||
| (Partial<BridgeSettings> & { codex?: unknown })
|
||||
| null;
|
||||
const { codex: _legacyCodex, ...loaded } = rawLoaded ?? {};
|
||||
this.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(loaded ?? {}),
|
||||
...loaded,
|
||||
activityTracking: {
|
||||
...DEFAULT_SETTINGS.activityTracking,
|
||||
...(loaded?.activityTracking ?? {}),
|
||||
...(loaded.activityTracking ?? {}),
|
||||
},
|
||||
toolToggles: {
|
||||
...DEFAULT_SETTINGS.toolToggles,
|
||||
...(loaded?.toolToggles ?? {}),
|
||||
...(loaded.toolToggles ?? {}),
|
||||
},
|
||||
toolContextLimits: {
|
||||
...DEFAULT_SETTINGS.toolContextLimits,
|
||||
...(loaded?.toolContextLimits ?? {}),
|
||||
...(loaded.toolContextLimits ?? {}),
|
||||
},
|
||||
llm: migrateLlm(loaded?.llm),
|
||||
inlineCompletion: migrateInlineCompletion(loaded?.inlineCompletion),
|
||||
ideIntegrations: {
|
||||
...DEFAULT_SETTINGS.ideIntegrations,
|
||||
...(loaded.ideIntegrations ?? {}),
|
||||
},
|
||||
llm: migrateLlm(loaded.llm),
|
||||
inlineCompletion: migrateInlineCompletion(loaded.inlineCompletion),
|
||||
};
|
||||
if (
|
||||
process.platform === "win32" &&
|
||||
|
|
@ -143,6 +170,25 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
() => this.settings.toolContextLimits.readCurrentWebPage,
|
||||
);
|
||||
|
||||
const pluginDir = path.join(
|
||||
getVaultRoot(this.app),
|
||||
this.app.vault.configDir,
|
||||
"plugins",
|
||||
this.manifest.id,
|
||||
);
|
||||
const customTmpDir = path.join(pluginDir, "tmp");
|
||||
const socketPath = path.join(
|
||||
customTmpDir,
|
||||
"codex-ipc",
|
||||
`ipc-${typeof process.getuid === "function" ? process.getuid() : 0}.sock`,
|
||||
);
|
||||
|
||||
this.codexIdeProvider = new CodexIdeProvider({
|
||||
getSnapshot: () => this.codexIdeContextSnapshot(),
|
||||
socketPath,
|
||||
onLog: (message) => console.error("[mv-senceai-ide]", message),
|
||||
});
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("active-leaf-change", () => {
|
||||
this.terminalTracker?.scan();
|
||||
|
|
@ -211,7 +257,13 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
this.llmFeature.registerCommands();
|
||||
this.llmFeature.registerMenus();
|
||||
|
||||
await this.startBridge();
|
||||
await this.syncLocalServices();
|
||||
void this.syncCodexIdeProvider();
|
||||
void syncAgentRulesInWorkspace(
|
||||
getVaultRoot(this.app),
|
||||
this.settings.ideIntegrations.syncClaudeRules,
|
||||
this.settings.ideIntegrations.syncCodexRules,
|
||||
);
|
||||
this.terminalTracker.scan();
|
||||
this.selectionHighlighter.sync(true);
|
||||
this.scheduleBroadcast();
|
||||
|
|
@ -224,19 +276,26 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
}
|
||||
this.unloaded = true;
|
||||
this.clearScheduledMcpRegistration();
|
||||
this.clearScheduledCodexMcpRegistration();
|
||||
this.selectionHighlighter?.destroy();
|
||||
this.selectionHighlighter = null;
|
||||
this.llmFeature?.dispose();
|
||||
this.llmFeature = null;
|
||||
this.inlineCompletion?.dispose();
|
||||
this.inlineCompletion = null;
|
||||
void removeCodexShellAlias();
|
||||
void this.finishUnload();
|
||||
}
|
||||
|
||||
async saveAndApplySettings(): Promise<void> {
|
||||
await this.applyClaudeSettingsBestEffort(true);
|
||||
await this.saveData(this.settings);
|
||||
this.scheduleMcpRegistration(true);
|
||||
await this.syncLocalServices(true);
|
||||
void this.syncCodexIdeProvider();
|
||||
void syncAgentRulesInWorkspace(
|
||||
getVaultRoot(this.app),
|
||||
this.settings.ideIntegrations.syncClaudeRules,
|
||||
this.settings.ideIntegrations.syncCodexRules,
|
||||
);
|
||||
}
|
||||
|
||||
refreshLlmFeature(): void {
|
||||
|
|
@ -255,7 +314,7 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
|
||||
async restartBridge(): Promise<void> {
|
||||
await this.stopBridge();
|
||||
await this.startBridge();
|
||||
await this.syncLocalServices(true);
|
||||
this.previousBroadcasts.clear();
|
||||
this.scheduleBroadcast();
|
||||
}
|
||||
|
|
@ -269,6 +328,7 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
|
||||
private async finishUnload(): Promise<void> {
|
||||
try {
|
||||
await this.codexIdeProvider?.stop();
|
||||
await this.restoreClaudeSettings();
|
||||
await this.closeDiffs();
|
||||
await this.stopBridge();
|
||||
|
|
@ -296,10 +356,60 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
private async startBridge(): Promise<void> {
|
||||
private shouldRunLocalServer(): boolean {
|
||||
return (
|
||||
this.settings.ideIntegrations.claudeCode ||
|
||||
(this.settings.ideIntegrations.codex && this.settings.mcpEnabled)
|
||||
);
|
||||
}
|
||||
|
||||
private async syncLocalServices(notifyClaude = false): Promise<void> {
|
||||
this.claudeIdeError = null;
|
||||
if (this.shouldRunLocalServer() && !this.server) {
|
||||
try {
|
||||
await this.startBridge();
|
||||
} catch (error) {
|
||||
this.claudeIdeError = error instanceof Error ? error.message : String(error);
|
||||
console.error("[mv-senceai-ide] Claude IDE bridge start failed", error);
|
||||
}
|
||||
} else if (!this.shouldRunLocalServer() && this.server) {
|
||||
await this.stopBridge();
|
||||
}
|
||||
|
||||
await this.syncClaudeIntegration(notifyClaude);
|
||||
this.scheduleCodexMcpRegistration();
|
||||
}
|
||||
|
||||
private async syncClaudeIntegration(notify = false): Promise<void> {
|
||||
if (!this.settings.ideIntegrations.claudeCode || !this.server || !this.port) {
|
||||
this.clearScheduledMcpRegistration();
|
||||
if (this.bridgeHasClaudeLock && this.port) removeLockFile(this.port);
|
||||
this.bridgeHasClaudeLock = false;
|
||||
if (!this.settings.ideIntegrations.claudeCode) {
|
||||
this.mcpStatus = "Claude Code IDE 已关闭";
|
||||
await this.restoreClaudeSettings();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
cleanStaleObsidianLocks();
|
||||
if (!this.bridgeHasClaudeLock) {
|
||||
writeLockFile(
|
||||
this.port,
|
||||
getVaultRoot(this.app),
|
||||
this.bridgeAuthToken ?? randomUUID(),
|
||||
);
|
||||
this.bridgeHasClaudeLock = true;
|
||||
}
|
||||
await this.applyClaudeSettingsBestEffort(notify);
|
||||
if (!this.unloaded) await this.saveData(this.settings);
|
||||
this.scheduleMcpRegistration();
|
||||
}
|
||||
|
||||
private async startBridge(): Promise<void> {
|
||||
const vaultRoot = getVaultRoot(this.app);
|
||||
const authToken = randomUUID();
|
||||
this.bridgeAuthToken = authToken;
|
||||
this.server = new BridgeServer({
|
||||
authToken,
|
||||
mcpAuthToken: this.settings.mcpAuthToken,
|
||||
|
|
@ -317,19 +427,17 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
onLog: (message) => console.error("[mv-senceai-ide]", message),
|
||||
});
|
||||
this.port = await this.server.start();
|
||||
writeLockFile(this.port, vaultRoot, authToken);
|
||||
await this.applyClaudeSettingsBestEffort();
|
||||
await this.saveData(this.settings);
|
||||
this.scheduleMcpRegistration();
|
||||
console.log(`[mv-senceai-ide] listening on 127.0.0.1:${this.port}`);
|
||||
}
|
||||
|
||||
private async stopBridge(): Promise<void> {
|
||||
const port = this.port;
|
||||
this.port = 0;
|
||||
this.bridgeAuthToken = null;
|
||||
await this.server?.stop();
|
||||
this.server = null;
|
||||
if (port) removeLockFile(port);
|
||||
if (port && this.bridgeHasClaudeLock) removeLockFile(port);
|
||||
this.bridgeHasClaudeLock = false;
|
||||
}
|
||||
|
||||
private async applyClaudeSettings(): Promise<void> {
|
||||
|
|
@ -449,6 +557,12 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
this.mcpRegistrationTimer = null;
|
||||
}
|
||||
|
||||
private clearScheduledCodexMcpRegistration(): void {
|
||||
if (this.codexMcpRegistrationTimer === null) return;
|
||||
activeWindow.clearTimeout(this.codexMcpRegistrationTimer);
|
||||
this.codexMcpRegistrationTimer = null;
|
||||
}
|
||||
|
||||
private scheduleMcpRegistration(force = false): void {
|
||||
this.clearScheduledMcpRegistration();
|
||||
if (!this.port) return;
|
||||
|
|
@ -505,10 +619,88 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
private scheduleCodexMcpRegistration(): void {
|
||||
this.clearScheduledCodexMcpRegistration();
|
||||
this.codexMcpStatus =
|
||||
this.settings.ideIntegrations.codex && this.settings.mcpEnabled
|
||||
? "Codex MCP 后台检查中"
|
||||
: "Codex MCP 未启用";
|
||||
this.codexMcpRegistrationTimer = activeWindow.setTimeout(() => {
|
||||
this.codexMcpRegistrationTimer = null;
|
||||
void this.runCodexMcpRegistration();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private async runCodexMcpRegistration(): Promise<void> {
|
||||
if (this.codexMcpRegistrationInFlight) {
|
||||
await this.codexMcpRegistrationInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
const task = this.syncCodexMcpRegistration();
|
||||
this.codexMcpRegistrationInFlight = task;
|
||||
try {
|
||||
await task;
|
||||
} finally {
|
||||
if (this.codexMcpRegistrationInFlight === task) {
|
||||
this.codexMcpRegistrationInFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async syncCodexMcpRegistration(): Promise<void> {
|
||||
if (!this.settings.ideIntegrations.codex || !this.settings.mcpEnabled) {
|
||||
const result = await removeCodexMcpRegistration();
|
||||
this.codexMcpStatus = result.ok
|
||||
? "Codex MCP 未启用"
|
||||
: `Codex MCP 清理失败:${result.message}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const url = this.currentMcpUrl();
|
||||
if (!url) {
|
||||
this.codexMcpStatus = "Codex MCP 等待本地服务";
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ensureCodexMcpRegistration(
|
||||
url,
|
||||
this.settings.mcpAuthToken,
|
||||
);
|
||||
this.codexMcpStatus = result.ok
|
||||
? result.message
|
||||
: `Codex MCP 配置失败:${result.message}`;
|
||||
}
|
||||
|
||||
private currentMcpUrl(): string | null {
|
||||
return this.port ? `http://127.0.0.1:${this.port}/mcp` : null;
|
||||
}
|
||||
|
||||
private async syncCodexIdeProvider(): Promise<void> {
|
||||
if (!this.codexIdeProvider) return;
|
||||
this.codexIdeError = null;
|
||||
if (!this.settings.ideIntegrations.codex) {
|
||||
await this.codexIdeProvider.stop();
|
||||
await removeCodexShellAlias();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.codexIdeProvider.start();
|
||||
const pluginDir = path.join(
|
||||
getVaultRoot(this.app),
|
||||
this.app.vault.configDir,
|
||||
"plugins",
|
||||
this.manifest.id,
|
||||
);
|
||||
const customTmpDir = path.join(pluginDir, "tmp");
|
||||
await ensureCodexShellAlias(customTmpDir, this.settings.codexExecutable || "codex");
|
||||
} catch (error) {
|
||||
this.codexIdeError = error instanceof Error ? error.message : String(error);
|
||||
console.error("[mv-senceai-ide] Codex IDE provider failed", error);
|
||||
await removeCodexShellAlias();
|
||||
}
|
||||
}
|
||||
|
||||
private async broadcastSelection(): Promise<void> {
|
||||
const generation = ++this.broadcastGeneration;
|
||||
const leaf = activeWorkspaceLeaf(this.app);
|
||||
|
|
@ -583,6 +775,20 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
return latestSelectionForContext(this.latestSelections, context);
|
||||
}
|
||||
|
||||
private async codexIdeContextSnapshot(): Promise<CodexIdeContextSnapshot> {
|
||||
const leaf = activeWorkspaceLeaf(this.app);
|
||||
const activeState =
|
||||
(await currentWorkspaceContext(this.app, leaf)) ??
|
||||
currentSelection(this.app);
|
||||
const current = this.resolveTrackedState(undefined, leaf, activeState);
|
||||
if (current) this.rememberState("global", current);
|
||||
return {
|
||||
vaultRoot: getVaultRoot(this.app),
|
||||
current,
|
||||
openEditors: getOpenWorkspaceTabs(this.app).tabs,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveTrackedState(
|
||||
context: BridgeClientContext | undefined,
|
||||
leaf: WorkspaceLeaf | null,
|
||||
|
|
@ -608,6 +814,10 @@ export default class MvSenceAiIdePlugin extends Plugin {
|
|||
}
|
||||
|
||||
private async syncMcpRegistration(force = false): Promise<void> {
|
||||
if (!this.settings.ideIntegrations.claudeCode) {
|
||||
this.mcpStatus = "Claude Code IDE 已关闭";
|
||||
return;
|
||||
}
|
||||
if (!this.port) return;
|
||||
if (!this.settings.mcpEnabled) {
|
||||
if (force || this.settings.registeredMcpUrl) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "mv-obcc",
|
||||
"name": "mv-SenceAI",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Connects local vaults to Claude Code with page tracking, session-aware terminals, MCP tools, editable diffs, and optional upstream compatibility.",
|
||||
"description": "Connects local vaults to Claude Code and Codex CLI with page tracking, Obsidian tools, editable diffs, and optional upstream compatibility.",
|
||||
"author": "MV",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
|
|
|
|||
4412
package-lock.json
generated
4412
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -1,14 +1,16 @@
|
|||
{
|
||||
"name": "mv-obcc",
|
||||
"version": "0.4.2",
|
||||
"description": "A focused Claude Code IDE bridge for Obsidian.",
|
||||
"version": "0.5.0",
|
||||
"description": "A focused Claude Code and Codex CLI IDE bridge for Obsidian.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"lint": "eslint main.ts src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify": "npm run typecheck && npm run build",
|
||||
"verify": "npm run lint && npm run typecheck && npm run build",
|
||||
"deploy:local": "npm run build && node scripts/deploy-local.mjs",
|
||||
"package": "node scripts/package.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -21,8 +23,11 @@
|
|||
"@types/archiver": "^6.0.3",
|
||||
"@types/node": "^22.18.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"obsidian": "^1.8.7",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
|
|
|
|||
130
src/agent-rules-manager.ts
Normal file
130
src/agent-rules-manager.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const TAG_BEGIN = "# BEGIN mv-SenceAI 规则";
|
||||
const TAG_END = "# END mv-SenceAI 规则";
|
||||
|
||||
const RULE_CONTENT = `${TAG_BEGIN}
|
||||
在该 Obsidian 仓库中编写非 Markdown 源代码(如 Python、Javascript、Matlab、HTML、Mathematica 等)时,请严格遵守以下规则:
|
||||
1. 文件命名:所有代码文件必须使用 \`-<lang_suffix>.md\` 结尾(例如 \`test-py.md\`、\`helper-js.md\`、\`plot-m.md\`),严禁直接创建原始代码后缀(如 \`.py\`、\`.js\`、\`.m\`)的文件。
|
||||
2. 内容格式:代码内容必须包裹在带有语言标识的 Markdown 代码块中,且在代码块的前后使用该语言的注释符号进行包裹。示例:
|
||||
- Python (文件以 -py.md 结尾):
|
||||
#
|
||||
\`\`\`python
|
||||
#
|
||||
print("Hello World")
|
||||
\`\`\`
|
||||
- Matlab (文件以 -m.md 结尾):
|
||||
%
|
||||
\`\`\`matlab
|
||||
%
|
||||
disp('Hello World')
|
||||
\`\`\`
|
||||
- Mathematica (文件以 -nb.md 或 -wls.md 结尾):
|
||||
(*
|
||||
\`\`\`mathematica
|
||||
*)
|
||||
(*
|
||||
Plot[Sin[x], {x, 0, 2 Pi}]
|
||||
*)
|
||||
- HTML (文件以 -html.md 结尾):
|
||||
<!--
|
||||
\`\`\`html
|
||||
-->
|
||||
<!--
|
||||
<div>Hello World</div>
|
||||
-->
|
||||
${TAG_END}`;
|
||||
|
||||
export async function syncAgentRulesInWorkspace(
|
||||
vaultRoot: string,
|
||||
syncClaude: boolean,
|
||||
syncCodex: boolean,
|
||||
): Promise<void> {
|
||||
await syncRuleFamily(
|
||||
[
|
||||
path.join(vaultRoot, "CLAUDE.md"),
|
||||
path.join(vaultRoot, ".claude", "CLAUDE.md"),
|
||||
],
|
||||
syncClaude,
|
||||
);
|
||||
await syncRuleFamily(
|
||||
[
|
||||
path.join(vaultRoot, "AGENTS.md"),
|
||||
path.join(vaultRoot, ".codex", "AGENTS.md"),
|
||||
],
|
||||
syncCodex,
|
||||
);
|
||||
}
|
||||
|
||||
async function syncRuleFamily(
|
||||
paths: readonly [string, ...string[]],
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
if (!enabled) {
|
||||
await Promise.all(
|
||||
paths.filter((filePath) => fs.existsSync(filePath)).map((filePath) =>
|
||||
updateFileRule(filePath, false),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = paths.filter((filePath) => fs.existsSync(filePath));
|
||||
const target =
|
||||
existing.find((filePath) => fileHasManagedBlock(filePath)) ??
|
||||
existing[0] ??
|
||||
paths[0];
|
||||
await updateFileRule(target, true);
|
||||
}
|
||||
|
||||
function fileHasManagedBlock(filePath: string): boolean {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
return content.includes(TAG_BEGIN) && content.includes(TAG_END);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFileRule(filePath: string, enabled: boolean): Promise<void> {
|
||||
try {
|
||||
let content = "";
|
||||
if (fs.existsSync(filePath)) {
|
||||
content = fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
// Strip existing rule block if present
|
||||
const start = content.indexOf(TAG_BEGIN);
|
||||
if (start >= 0) {
|
||||
const end = content.indexOf(TAG_END, start);
|
||||
if (end >= 0) {
|
||||
content =
|
||||
content.slice(0, start).trimEnd() +
|
||||
"\n\n" +
|
||||
content.slice(end + TAG_END.length).trimStart();
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
content = content.trimEnd() + "\n\n" + RULE_CONTENT + "\n";
|
||||
} else {
|
||||
content = content.trimEnd() + "\n";
|
||||
}
|
||||
|
||||
// Clean up empty lines at the end
|
||||
content = content.trim() === "" ? "" : content;
|
||||
|
||||
if (content === "") {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} else {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[mv-obcc] Failed to update rule file ${filePath}`, e);
|
||||
}
|
||||
}
|
||||
|
|
@ -75,8 +75,8 @@ export class BridgeServer {
|
|||
private websocketServer: WebSocketServer | null = null;
|
||||
private clients = new Map<WebSocket, BridgeClientContext>();
|
||||
private mcpSessions = new Map<string, BridgeClientContext>();
|
||||
private readonly identityByRemotePort = new Map<
|
||||
number,
|
||||
private readonly identityBySocket = new WeakMap<
|
||||
net.Socket,
|
||||
Promise<Pick<BridgeClientContext, "processId" | "sessionId">>
|
||||
>();
|
||||
private heartbeat: NodeJS.Timeout | null = null;
|
||||
|
|
@ -122,7 +122,6 @@ export class BridgeServer {
|
|||
this.websocketServer?.close();
|
||||
this.websocketServer = null;
|
||||
this.mcpSessions.clear();
|
||||
this.identityByRemotePort.clear();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.server) return resolve();
|
||||
this.server.close(() => resolve());
|
||||
|
|
@ -160,7 +159,7 @@ export class BridgeServer {
|
|||
this.options.onClientContextChanged?.({ ...context });
|
||||
const remotePort = request.socket.remotePort;
|
||||
if (remotePort) {
|
||||
void this.resolveContextIdentity(context, remotePort).then(() => {
|
||||
void this.resolveContextIdentity(context, request.socket, remotePort).then(() => {
|
||||
this.options.onClientContextChanged?.({ ...context });
|
||||
});
|
||||
}
|
||||
|
|
@ -368,7 +367,9 @@ export class BridgeServer {
|
|||
channel: "mcp",
|
||||
};
|
||||
const remotePort = request.socket.remotePort;
|
||||
if (remotePort) await this.resolveContextIdentity(context, remotePort);
|
||||
if (remotePort) {
|
||||
await this.resolveContextIdentity(context, request.socket, remotePort);
|
||||
}
|
||||
}
|
||||
const rpcResponse = await this.options.onMcpMessage(rpcRequest, {
|
||||
...context,
|
||||
|
|
@ -405,9 +406,10 @@ export class BridgeServer {
|
|||
|
||||
private async resolveContextIdentity(
|
||||
context: BridgeClientContext,
|
||||
socket: net.Socket,
|
||||
remotePort: number,
|
||||
): Promise<void> {
|
||||
let pending = this.identityByRemotePort.get(remotePort);
|
||||
let pending = this.identityBySocket.get(socket);
|
||||
if (!pending) {
|
||||
const resolver =
|
||||
this.options.resolveClientIdentity ?? resolveClaudeClientIdentity;
|
||||
|
|
@ -421,7 +423,7 @@ export class BridgeServer {
|
|||
: {},
|
||||
)
|
||||
.catch(() => ({}));
|
||||
this.identityByRemotePort.set(remotePort, pending);
|
||||
this.identityBySocket.set(socket, pending);
|
||||
}
|
||||
Object.assign(context, await pending);
|
||||
}
|
||||
|
|
|
|||
300
src/codex-ide-provider.ts
Normal file
300
src/codex-ide-provider.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenEditorTab, SelectionState } from "./types";
|
||||
|
||||
export interface CodexIdeContextSnapshot {
|
||||
vaultRoot: string;
|
||||
current: SelectionState | null;
|
||||
openEditors: OpenEditorTab[];
|
||||
}
|
||||
|
||||
interface CodexIdeProviderOptions {
|
||||
getSnapshot: () => Promise<CodexIdeContextSnapshot>;
|
||||
socketPath?: string;
|
||||
clientId?: string;
|
||||
onLog?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface PendingFrame {
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
const CODEX_TUI_CLIENT_ID = "codex-tui";
|
||||
const DEFAULT_CLIENT_ID = "mv-senceai-obsidian";
|
||||
const MAX_FRAME_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
export function defaultCodexIdeSocketPath(): string {
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\codex-ipc";
|
||||
}
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
||||
return path.join(os.tmpdir(), "codex-ipc", `ipc-${uid}.sock`);
|
||||
}
|
||||
|
||||
export function encodeCodexIdeFrame(message: unknown): Buffer {
|
||||
const payload = Buffer.from(JSON.stringify(message), "utf8");
|
||||
const header = Buffer.allocUnsafe(4);
|
||||
header.writeUInt32LE(payload.length, 0);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function decodeCodexIdeFrames(state: PendingFrame): unknown[] {
|
||||
const messages: unknown[] = [];
|
||||
while (state.buffer.length >= 4) {
|
||||
const length = state.buffer.readUInt32LE(0);
|
||||
if (length > MAX_FRAME_BYTES) {
|
||||
throw new Error("Codex IDE context frame is too large.");
|
||||
}
|
||||
if (state.buffer.length < 4 + length) break;
|
||||
const payload = state.buffer.subarray(4, 4 + length);
|
||||
messages.push(JSON.parse(payload.toString("utf8")));
|
||||
state.buffer = state.buffer.subarray(4 + length);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function isPathInside(child: string, parent: string): boolean {
|
||||
const resolvedChild = path.resolve(child);
|
||||
const resolvedParent = path.resolve(parent);
|
||||
const relative = path.relative(resolvedParent, resolvedChild);
|
||||
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function selectionRange(state: SelectionState) {
|
||||
return {
|
||||
start: {
|
||||
line: state.selection.start.line,
|
||||
character: state.selection.start.character,
|
||||
},
|
||||
end: {
|
||||
line: state.selection.end.line,
|
||||
character: state.selection.end.character,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function descriptorFromSelection(state: SelectionState) {
|
||||
return {
|
||||
label: state.title || path.basename(state.relativePath || state.filePath),
|
||||
path: state.relativePath || state.url || state.filePath,
|
||||
fsPath:
|
||||
state.resourceType === "markdown" ||
|
||||
state.resourceType === "pdf" ||
|
||||
state.resourceType === "file"
|
||||
? state.filePath
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function descriptorFromTab(tab: OpenEditorTab) {
|
||||
return {
|
||||
label: tab.label || tab.relativePath || tab.url || tab.uri,
|
||||
path: tab.relativePath || tab.url || tab.uri,
|
||||
fsPath: tab.filePath,
|
||||
};
|
||||
}
|
||||
|
||||
function buildIdeContext(snapshot: CodexIdeContextSnapshot) {
|
||||
return {
|
||||
activeFile: snapshot.current
|
||||
? {
|
||||
...descriptorFromSelection(snapshot.current),
|
||||
selection: selectionRange(snapshot.current),
|
||||
activeSelectionContent: snapshot.current.selection.text,
|
||||
selections: [selectionRange(snapshot.current)],
|
||||
}
|
||||
: null,
|
||||
openTabs: snapshot.openEditors.map(descriptorFromTab),
|
||||
};
|
||||
}
|
||||
|
||||
async function activeUnixSocketExists(socketPath: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, 150);
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(timer);
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareUnixSocket(socketPath: string): Promise<void> {
|
||||
const directory = path.dirname(socketPath);
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
fs.chmodSync(directory, 0o700);
|
||||
} catch {
|
||||
// Best effort: Codex validates ownership and write bits before connecting.
|
||||
}
|
||||
|
||||
if (!fs.existsSync(socketPath)) return;
|
||||
if (await activeUnixSocketExists(socketPath)) {
|
||||
throw new Error(`Codex IDE socket is already in use: ${socketPath}`);
|
||||
}
|
||||
fs.unlinkSync(socketPath);
|
||||
}
|
||||
|
||||
export class CodexIdeProvider {
|
||||
private server: net.Server | null = null;
|
||||
private socketPath = "";
|
||||
|
||||
constructor(private readonly options: CodexIdeProviderOptions) {}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.server) return;
|
||||
const socketPath = this.options.socketPath ?? defaultCodexIdeSocketPath();
|
||||
if (process.platform !== "win32") {
|
||||
await prepareUnixSocket(socketPath);
|
||||
}
|
||||
|
||||
const server = net.createServer((socket) => this.handleConnection(socket));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
server.off("error", onError);
|
||||
server.off("listening", onListening);
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.once("listening", onListening);
|
||||
server.listen(socketPath);
|
||||
});
|
||||
this.server = server;
|
||||
this.socketPath = socketPath;
|
||||
this.options.onLog?.(`Codex IDE provider listening on ${socketPath}`);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const server = this.server;
|
||||
const socketPath = this.socketPath;
|
||||
this.server = null;
|
||||
this.socketPath = "";
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!server) return resolve();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
if (socketPath && process.platform !== "win32") {
|
||||
try {
|
||||
fs.unlinkSync(socketPath);
|
||||
} catch {
|
||||
// Already removed or owned by another process.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnection(socket: net.Socket): void {
|
||||
const state: PendingFrame = { buffer: Buffer.alloc(0) };
|
||||
socket.on("data", (chunk) => {
|
||||
try {
|
||||
state.buffer = Buffer.concat([state.buffer, Buffer.from(chunk)]);
|
||||
for (const message of decodeCodexIdeFrames(state)) {
|
||||
void this.handleMessage(socket, message).catch((error) => {
|
||||
this.options.onLog?.(`Codex IDE request failed: ${String(error)}`);
|
||||
socket.destroy();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.onLog?.(`Codex IDE frame error: ${String(error)}`);
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
socket.on("error", (error) => {
|
||||
this.options.onLog?.(`Codex IDE socket error: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage(socket: net.Socket, message: unknown): Promise<void> {
|
||||
const request = asRecord(message);
|
||||
const type = typeof request.type === "string" ? request.type : "";
|
||||
if (type === "client-discovery-request") {
|
||||
this.write(socket, {
|
||||
type: "client-discovery-response",
|
||||
requestId: request.requestId,
|
||||
response: { canHandle: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type !== "request") return;
|
||||
|
||||
const requestId = String(request.requestId ?? "");
|
||||
const method = String(request.method ?? "");
|
||||
if (!requestId) return;
|
||||
if (method !== "ide-context") {
|
||||
this.writeError(socket, requestId, "no-handler-for-request");
|
||||
return;
|
||||
}
|
||||
|
||||
const params = asRecord(request.params);
|
||||
const workspaceRoot = String(params.workspaceRoot ?? "");
|
||||
const snapshot = await this.options.getSnapshot();
|
||||
if (!workspaceRoot || !isPathInside(workspaceRoot, snapshot.vaultRoot)) {
|
||||
this.writeError(socket, requestId, "no-client-found", method);
|
||||
return;
|
||||
}
|
||||
|
||||
this.write(socket, {
|
||||
type: "response",
|
||||
requestId,
|
||||
resultType: "success",
|
||||
method,
|
||||
handledByClientId: this.options.clientId ?? DEFAULT_CLIENT_ID,
|
||||
result: {
|
||||
type: "broadcast",
|
||||
ideContext: buildIdeContext(snapshot),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private writeError(
|
||||
socket: net.Socket,
|
||||
requestId: string,
|
||||
error: string,
|
||||
method?: string,
|
||||
): void {
|
||||
this.write(socket, {
|
||||
type: "response",
|
||||
requestId,
|
||||
resultType: "error",
|
||||
...(method ? { method } : {}),
|
||||
handledByClientId: this.options.clientId ?? DEFAULT_CLIENT_ID,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
private write(socket: net.Socket, message: unknown): void {
|
||||
const sourceClientId =
|
||||
asRecord(message).type === "request" ? CODEX_TUI_CLIENT_ID : undefined;
|
||||
socket.write(
|
||||
encodeCodexIdeFrame(
|
||||
sourceClientId ? { ...(message as Record<string, unknown>), sourceClientId } : message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
src/codex-mcp-registration.ts
Normal file
165
src/codex-mcp-registration.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export interface CodexMcpRegistrationResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface CodexMcpRegistrationRuntime {
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
const MANAGED_BEGIN = "# BEGIN mv-SenceAI managed Codex MCP server";
|
||||
const MANAGED_END = "# END mv-SenceAI managed Codex MCP server";
|
||||
const SERVER_NAME = "mv_senceai_obsidian";
|
||||
|
||||
export function defaultCodexConfigPath(): string {
|
||||
return path.join(os.homedir(), ".codex", "config.toml");
|
||||
}
|
||||
|
||||
function tomlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function managedBlock(url: string, authToken: string): string {
|
||||
return [
|
||||
MANAGED_BEGIN,
|
||||
`[mcp_servers.${SERVER_NAME}]`,
|
||||
`url = ${tomlString(url)}`,
|
||||
`http_headers = { Authorization = ${tomlString(`Bearer ${authToken}`)} }`,
|
||||
"enabled = true",
|
||||
"startup_timeout_sec = 10",
|
||||
"tool_timeout_sec = 60",
|
||||
MANAGED_END,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function stripManagedBlock(content: string): string {
|
||||
const start = content.indexOf(MANAGED_BEGIN);
|
||||
if (start < 0) return content;
|
||||
const end = content.indexOf(MANAGED_END, start);
|
||||
if (end < 0) return content;
|
||||
const before = content.slice(0, start).trimEnd();
|
||||
const after = content.slice(end + MANAGED_END.length).trimStart();
|
||||
return [before, after].filter(Boolean).join("\n\n") + (before || after ? "\n" : "");
|
||||
}
|
||||
|
||||
function upsertManagedBlock(content: string, block: string): string {
|
||||
const stripped = stripManagedBlock(content).trimEnd();
|
||||
return stripped ? `${stripped}\n\n${block}\n` : `${block}\n`;
|
||||
}
|
||||
|
||||
function readIfExists(filePath: string): string {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFileAtomic(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${filePath}.mv-senceai.tmp`;
|
||||
fs.writeFileSync(temporary, content, { mode: 0o600 });
|
||||
fs.renameSync(temporary, filePath);
|
||||
}
|
||||
|
||||
export async function ensureCodexMcpRegistration(
|
||||
url: string,
|
||||
authToken: string,
|
||||
runtime: CodexMcpRegistrationRuntime = {},
|
||||
): Promise<CodexMcpRegistrationResult> {
|
||||
try {
|
||||
const configPath = runtime.configPath ?? defaultCodexConfigPath();
|
||||
const current = readIfExists(configPath);
|
||||
writeFileAtomic(configPath, upsertManagedBlock(current, managedBlock(url, authToken)));
|
||||
return { ok: true, message: "Codex MCP 已配置" };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCodexMcpRegistration(
|
||||
runtime: CodexMcpRegistrationRuntime = {},
|
||||
): Promise<CodexMcpRegistrationResult> {
|
||||
try {
|
||||
const configPath = runtime.configPath ?? defaultCodexConfigPath();
|
||||
const current = readIfExists(configPath);
|
||||
const next = stripManagedBlock(current);
|
||||
if (next !== current) writeFileAtomic(configPath, next);
|
||||
return { ok: true, message: "Codex MCP 已移除" };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ALIAS_MANAGED_BEGIN = "# BEGIN mv-obcc managed Codex environment";
|
||||
const ALIAS_MANAGED_END = "# END mv-obcc managed Codex environment";
|
||||
|
||||
export async function ensureCodexShellAlias(tmpDir: string, codexExecutable: string = "codex"): Promise<void> {
|
||||
const home = os.homedir();
|
||||
const targets = [
|
||||
path.join(home, ".zshrc"),
|
||||
path.join(home, ".bash_profile"),
|
||||
path.join(home, ".bashrc"),
|
||||
];
|
||||
const block = [
|
||||
ALIAS_MANAGED_BEGIN,
|
||||
`alias codex="TMPDIR='${tmpDir}' ${codexExecutable}"`,
|
||||
ALIAS_MANAGED_END,
|
||||
].join("\n");
|
||||
|
||||
for (const target of targets) {
|
||||
if (!fs.existsSync(target)) continue;
|
||||
try {
|
||||
let content = fs.readFileSync(target, "utf8");
|
||||
// strip old block first
|
||||
const start = content.indexOf(ALIAS_MANAGED_BEGIN);
|
||||
if (start >= 0) {
|
||||
const end = content.indexOf(ALIAS_MANAGED_END, start);
|
||||
if (end >= 0) {
|
||||
content = content.slice(0, start).trimEnd() + "\n\n" + content.slice(end + ALIAS_MANAGED_END.length).trimStart();
|
||||
}
|
||||
}
|
||||
content = content.trimEnd() + "\n\n" + block + "\n";
|
||||
fs.writeFileSync(target, content, "utf8");
|
||||
} catch (e) {
|
||||
console.error(`[mv-obcc] Failed to write shell alias to ${target}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCodexShellAlias(): Promise<void> {
|
||||
const home = os.homedir();
|
||||
const targets = [
|
||||
path.join(home, ".zshrc"),
|
||||
path.join(home, ".bash_profile"),
|
||||
path.join(home, ".bashrc"),
|
||||
];
|
||||
|
||||
for (const target of targets) {
|
||||
if (!fs.existsSync(target)) continue;
|
||||
try {
|
||||
let content = fs.readFileSync(target, "utf8");
|
||||
const start = content.indexOf(ALIAS_MANAGED_BEGIN);
|
||||
if (start >= 0) {
|
||||
const end = content.indexOf(ALIAS_MANAGED_END, start);
|
||||
if (end >= 0) {
|
||||
content = content.slice(0, start).trimEnd() + "\n\n" + content.slice(end + ALIAS_MANAGED_END.length).trimStart();
|
||||
fs.writeFileSync(target, content.trimEnd() + "\n", "utf8");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[mv-obcc] Failed to remove shell alias from ${target}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,12 @@ export const DEFAULT_SETTINGS = {
|
|||
toolContextLimits: {
|
||||
readCurrentWebPage: null,
|
||||
},
|
||||
ideIntegrations: {
|
||||
claudeCode: true,
|
||||
codex: false,
|
||||
syncClaudeRules: true,
|
||||
syncCodexRules: true,
|
||||
},
|
||||
llm: {
|
||||
enabled: false,
|
||||
providers: [
|
||||
|
|
@ -140,6 +146,7 @@ export const DEFAULT_SETTINGS = {
|
|||
mcpEnabled: true,
|
||||
mcpAuthToken: "",
|
||||
claudeExecutable: "",
|
||||
codexExecutable: "codex",
|
||||
registeredMcpUrl: null,
|
||||
windowsMcpRegistrationVersion: 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
export type DiffDecision = "accept" | "reject";
|
||||
|
||||
export class DiffSession {
|
||||
private resolved = false;
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly originalContents: string,
|
||||
readonly proposedContents: string,
|
||||
private readonly resolver: (decision: DiffDecision, contents: string) => void,
|
||||
) {}
|
||||
|
||||
resolve(decision: DiffDecision, contents = this.proposedContents): boolean {
|
||||
if (this.resolved) return false;
|
||||
this.resolved = true;
|
||||
this.resolver(decision, contents);
|
||||
return true;
|
||||
}
|
||||
|
||||
get isResolved(): boolean {
|
||||
return this.resolved;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ export interface InlineCompletionControllerDeps {
|
|||
handlers: KeymapHandlers;
|
||||
/** Called on every view update; feature code classifies trigger/cancel/ignore. */
|
||||
onViewUpdate?: (update: ViewUpdate) => void;
|
||||
/** Called when a CodeMirror editor view is destroyed. */
|
||||
onViewDestroy?: (view: EditorView) => void;
|
||||
}
|
||||
|
||||
export class InlineCompletionController {
|
||||
|
|
@ -57,6 +59,7 @@ export class InlineCompletionController {
|
|||
return {
|
||||
destroy() {
|
||||
controller.views.delete(view);
|
||||
controller.deps.onViewDestroy?.(view);
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import {
|
|||
import {
|
||||
displayableCompletionStreamText,
|
||||
finalCompletionText,
|
||||
NO_COMPLETION_SENTINEL,
|
||||
} from "./inline-completion-protocol";
|
||||
import { buildRejectUserMessage } from "./inline-reject-prompt";
|
||||
import { readSuggestion } from "./inline-suggestion-state";
|
||||
|
|
@ -87,6 +86,7 @@ export class InlineCompletionFeature {
|
|||
onRequest: (view) => this.handleManualRequest(view),
|
||||
},
|
||||
onViewUpdate: (update) => this.handleViewUpdate(update),
|
||||
onViewDestroy: (view) => this.handleViewDestroy(view),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -488,6 +488,12 @@ export class InlineCompletionFeature {
|
|||
if (clearVisible) this.controller.clearSuggestion(view);
|
||||
}
|
||||
|
||||
private handleViewDestroy(view: EditorView): void {
|
||||
this.cancelView(view, false);
|
||||
this.conversations.delete(view);
|
||||
this.activeRequestIds.delete(view);
|
||||
}
|
||||
|
||||
/** Cancel all timers/requests and clear all visible suggestions. */
|
||||
private cancelAll(): void {
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
|
|
@ -496,6 +502,8 @@ export class InlineCompletionFeature {
|
|||
this.debounceTimers.clear();
|
||||
for (const ac of this.abortControllers.values()) ac.abort();
|
||||
this.abortControllers.clear();
|
||||
this.conversations.clear();
|
||||
this.activeRequestIds.clear();
|
||||
this.controller.clearAll();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
DEFAULT_INLINE_SYSTEM_PROMPT_BODY,
|
||||
DEFAULT_INLINE_NO_COMPLETION_PROMPT,
|
||||
} from "../constants";
|
||||
import { DEFAULT_SETTINGS } from "../constants";
|
||||
import type {
|
||||
InlineCompletionKeymap,
|
||||
InlineCompletionSettings,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ class GhostTextWidget extends WidgetType {
|
|||
return other.text === this.text;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement("span");
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const span = view.dom.ownerDocument.createElement("span");
|
||||
span.textContent = this.text;
|
||||
span.className = "mv-senceai-ghost-text";
|
||||
return span;
|
||||
|
|
|
|||
|
|
@ -35,22 +35,6 @@ export type NormalizedKey =
|
|||
| { kind: "code"; code: string }
|
||||
| { kind: "key"; key: string };
|
||||
|
||||
const KEY_NORMALIZED: ReadonlySet<string> = new Set([
|
||||
"Escape",
|
||||
"Enter",
|
||||
"Tab",
|
||||
"Backspace",
|
||||
"Space",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Normalize a stored key into a match strategy.
|
||||
* - Single ASCII letter → match `event.code` as `KeyX` (mac Option-safe).
|
||||
|
|
|
|||
|
|
@ -133,7 +133,6 @@ export class LlmResultSurface implements LlmResultSink {
|
|||
private errorMessage = "";
|
||||
private fallbackReason = "";
|
||||
private rootEl: HTMLElement | null = null;
|
||||
private titleEl: HTMLElement | null = null;
|
||||
private statusEl: HTMLElement | null = null;
|
||||
private hostEl: HTMLElement | null = null;
|
||||
private toolbarEl: HTMLElement | null = null;
|
||||
|
|
@ -273,7 +272,6 @@ export class LlmResultSurface implements LlmResultSink {
|
|||
}
|
||||
this.rootEl?.remove();
|
||||
this.rootEl = null;
|
||||
this.titleEl = null;
|
||||
this.statusEl = null;
|
||||
this.hostEl = null;
|
||||
this.toolbarEl = null;
|
||||
|
|
@ -407,7 +405,6 @@ export class LlmResultSurface implements LlmResultSink {
|
|||
this.doc.body.appendChild(root);
|
||||
|
||||
this.rootEl = root;
|
||||
this.titleEl = title;
|
||||
this.statusEl = status;
|
||||
this.hostEl = host;
|
||||
this.toolbarEl = toolbar;
|
||||
|
|
|
|||
|
|
@ -43,27 +43,70 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
|
|||
containerEl.empty();
|
||||
addHeading(containerEl, "mv-SenceAI IDE");
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("桥接状态")
|
||||
.setDesc("插件启用后被动运行。建议先启动 Obsidian,再启动 Claude Code。")
|
||||
.addText((text) => {
|
||||
text
|
||||
.setValue(
|
||||
this.plugin.port
|
||||
? `已连接:127.0.0.1:${this.plugin.port}`
|
||||
: "未连接",
|
||||
)
|
||||
.setDisabled(true);
|
||||
text.inputEl.addClass("mv-senceai-status");
|
||||
});
|
||||
|
||||
containerEl.createEl("div", {
|
||||
text: "🔌 IDE 桥接",
|
||||
cls: "mv-senceai-section-title setting-item-name",
|
||||
});
|
||||
|
||||
const claudeSetting = new Setting(containerEl)
|
||||
.setName("启用 Claude Code IDE 功能")
|
||||
.setDesc("默认开启。关闭后不写 Claude IDE lock、不注册 Claude MCP、不接管 Claude 设置。")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.ideIntegrations.claudeCode)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ideIntegrations.claudeCode = value;
|
||||
await this.plugin.saveAndApplySettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
const claudeStatusEl = claudeSetting.settingEl.createEl("span", {
|
||||
cls: "mv-senceai-status-indicator",
|
||||
});
|
||||
if (!this.plugin.settings.ideIntegrations.claudeCode) {
|
||||
claudeStatusEl.setText("状态:已禁用");
|
||||
claudeStatusEl.addClass("mv-senceai-status-muted");
|
||||
} else if (this.plugin.claudeIdeError) {
|
||||
claudeStatusEl.setText(`● 启动失败: ${this.plugin.claudeIdeError}`);
|
||||
claudeStatusEl.addClass("mv-senceai-status-error");
|
||||
} else {
|
||||
claudeStatusEl.setText("● 运行中");
|
||||
claudeStatusEl.addClass("mv-senceai-status-success");
|
||||
}
|
||||
|
||||
const codexSetting = new Setting(containerEl)
|
||||
.setName("启用 Codex IDE 功能")
|
||||
.setDesc("默认关闭。开启后支持 Codex CLI /ide,并把本插件 MCP 工具写入 Codex 配置。")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.ideIntegrations.codex)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ideIntegrations.codex = value;
|
||||
await this.plugin.saveAndApplySettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
const codexStatusEl = codexSetting.settingEl.createEl("span", {
|
||||
cls: "mv-senceai-status-indicator",
|
||||
});
|
||||
if (!this.plugin.settings.ideIntegrations.codex) {
|
||||
codexStatusEl.setText("状态:已禁用");
|
||||
codexStatusEl.addClass("mv-senceai-status-muted");
|
||||
} else if (this.plugin.codexIdeError) {
|
||||
codexStatusEl.setText(`● 启动失败: ${this.plugin.codexIdeError}`);
|
||||
codexStatusEl.addClass("mv-senceai-status-error");
|
||||
} else {
|
||||
codexStatusEl.setText("● 运行中");
|
||||
codexStatusEl.addClass("mv-senceai-status-success");
|
||||
}
|
||||
|
||||
addHeading(containerEl, "功能与工具");
|
||||
|
||||
addHeading(containerEl, "被动:状态感知");
|
||||
addHeading(containerEl, "被动");
|
||||
|
||||
addHeading(containerEl, "状态感知");
|
||||
new Setting(containerEl)
|
||||
.setName("支持所有活动页面")
|
||||
.setDesc(
|
||||
|
|
@ -121,6 +164,34 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
|
|||
);
|
||||
}
|
||||
|
||||
addHeading(containerEl, "非 MD 源码编写");
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("同步 Claude Code (CLAUDE.md) 规则")
|
||||
.setDesc("启用后自动在 CLAUDE.md 中注入中文规约,指导 AI 使用 md 代码文件(文件名-后缀.md)保存非 Markdown 代码。注意:开启本项会直接创建或修改此规则文件。")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.ideIntegrations.syncClaudeRules)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ideIntegrations.syncClaudeRules = value;
|
||||
await this.plugin.saveAndApplySettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("同步 Codex (AGENTS.md) 规则")
|
||||
.setDesc("启用后自动在 AGENTS.md 中注入中文规约,指导 AI 使用 md 代码文件(文件名-后缀.md)保存非 Markdown 代码。注意:开启本项会直接创建或修改此规则文件。")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.ideIntegrations.syncCodexRules)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ideIntegrations.syncCodexRules = value;
|
||||
await this.plugin.saveAndApplySettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
addHeading(containerEl, "视觉辅助");
|
||||
new Setting(containerEl)
|
||||
.setName("切换标签时保留选区高亮")
|
||||
|
|
@ -139,7 +210,7 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
|
|||
new Setting(containerEl)
|
||||
.setName("启用 MCP 主动工具")
|
||||
.setDesc(
|
||||
"Claude Code 会过滤普通 IDE 工具,因此主动工具通过标准 MCP 提供。改变后请重新启动 Claude Code。",
|
||||
"主动工具通过标准 MCP 提供给 Claude Code 和 Codex CLI。改变后请重启对应客户端或重新执行 /mcp。",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
|
|
@ -254,6 +325,19 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveData(this.plugin.settings);
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Codex 可执行文件")
|
||||
.setDesc("通常自动检测为 codex。自定义安装位置可在此填写完整路径。")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("codex")
|
||||
.setValue(this.plugin.settings.codexExecutable)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.codexExecutable = value.trim();
|
||||
await this.plugin.saveData(this.plugin.settings);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
addHeading(containerEl, "上游兼容");
|
||||
|
|
@ -344,6 +428,8 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
|
|||
}),
|
||||
);
|
||||
|
||||
|
||||
|
||||
containerEl.createEl("div", {
|
||||
text: "🤖 API 提供商(划词助手与行内补全共用)",
|
||||
cls: "mv-senceai-section-title setting-item-name",
|
||||
|
|
|
|||
13
src/types.ts
13
src/types.ts
|
|
@ -19,6 +19,17 @@ export interface ToolContextLimits {
|
|||
readCurrentWebPage: number | null;
|
||||
}
|
||||
|
||||
export interface IdeIntegrationSettings {
|
||||
/** Claude Code IDE bridge. Fresh installs keep the original behavior enabled. */
|
||||
claudeCode: boolean;
|
||||
/** Codex CLI /ide context provider and managed MCP config. Fresh installs default off. */
|
||||
codex: boolean;
|
||||
/** Whether to sync markdown coding rules to CLAUDE.md */
|
||||
syncClaudeRules: boolean;
|
||||
/** Whether to sync markdown coding rules to AGENTS.md */
|
||||
syncCodexRules: boolean;
|
||||
}
|
||||
|
||||
export type LlmProviderType = "openai" | "anthropic";
|
||||
|
||||
/** How a model wants "thinking" params applied (gated by the template toggle). */
|
||||
|
|
@ -163,11 +174,13 @@ export interface BridgeSettings {
|
|||
preserveSelectionHighlights: boolean;
|
||||
toolToggles: ToolToggles;
|
||||
toolContextLimits: ToolContextLimits;
|
||||
ideIntegrations: IdeIntegrationSettings;
|
||||
llm: LlmFeatureSettings;
|
||||
inlineCompletion: InlineCompletionSettings;
|
||||
mcpEnabled: boolean;
|
||||
mcpAuthToken: string;
|
||||
claudeExecutable: string;
|
||||
codexExecutable: string;
|
||||
registeredMcpUrl: string | null;
|
||||
windowsMcpRegistrationVersion: number;
|
||||
}
|
||||
|
|
|
|||
21
styles.css
21
styles.css
|
|
@ -53,11 +53,6 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.mv-senceai-status {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.mv-senceai-persistent-selection {
|
||||
background-color: var(--text-selection);
|
||||
}
|
||||
|
|
@ -371,6 +366,22 @@
|
|||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mv-senceai-status-indicator {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.mv-senceai-status-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mv-senceai-status-error {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.mv-senceai-status-success {
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
/* 划词助手:API 提供商卡片 */
|
||||
.mv-senceai-llm-provider {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
|
|
|
|||
|
|
@ -18,5 +18,6 @@
|
|||
"0.3.8": "1.7.2",
|
||||
"0.4.0": "1.7.2",
|
||||
"0.4.1": "1.7.2",
|
||||
"0.4.2": "1.7.2"
|
||||
"0.4.5": "1.7.2",
|
||||
"0.5.0": "1.7.2"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue