codexmate 0.1.10 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +5 -7
  2. package/README.vi.md +4 -7
  3. package/README.zh.md +5 -7
  4. package/cli/agents-files.js +27 -3
  5. package/cli/prompt-history.js +171 -0
  6. package/cli/system-prompt-files.js +20 -2
  7. package/cli.js +162 -5
  8. package/package.json +1 -1
  9. package/web-ui/app.js +95 -4
  10. package/web-ui/index.html +2 -1
  11. package/web-ui/modules/app.methods.agents.mjs +90 -0
  12. package/web-ui/modules/app.methods.index.mjs +2 -0
  13. package/web-ui/modules/app.methods.navigation.mjs +7 -1
  14. package/web-ui/modules/app.methods.pi-config.mjs +371 -0
  15. package/web-ui/modules/app.methods.session-actions.mjs +57 -0
  16. package/web-ui/modules/app.methods.startup-claude.mjs +2 -1
  17. package/web-ui/modules/app.methods.system-prompt.mjs +81 -0
  18. package/web-ui/modules/app.methods.tool-config-permissions.mjs +3 -2
  19. package/web-ui/modules/app.methods.web-ui-preferences.mjs +8 -1
  20. package/web-ui/modules/config-mode.computed.mjs +2 -1
  21. package/web-ui/modules/i18n/locales/en.mjs +52 -0
  22. package/web-ui/modules/i18n/locales/ja.mjs +52 -0
  23. package/web-ui/modules/i18n/locales/vi.mjs +52 -0
  24. package/web-ui/modules/i18n/locales/zh-tw.mjs +52 -0
  25. package/web-ui/modules/i18n/locales/zh.mjs +52 -0
  26. package/web-ui/partials/index/layout-header.html +20 -0
  27. package/web-ui/partials/index/panel-config-claude.html +3 -3
  28. package/web-ui/partials/index/panel-config-codex.html +3 -3
  29. package/web-ui/partials/index/panel-config-kilocode.html +5 -5
  30. package/web-ui/partials/index/panel-config-openclaw.html +3 -3
  31. package/web-ui/partials/index/panel-config-opencode.html +5 -5
  32. package/web-ui/partials/index/panel-config-pi.html +171 -0
  33. package/web-ui/partials/index/panel-prompts.html +52 -0
  34. package/web-ui/partials/index/panel-sessions.html +12 -0
  35. package/web-ui/partials/index/panel-settings.html +42 -0
  36. package/web-ui/res/web-ui-render.precompiled.js +1101 -252
  37. package/web-ui/styles/modals-core.css +86 -0
  38. package/web-ui/styles/sessions-preview.css +38 -0
  39. package/web-ui/styles/settings-panel.css +7 -0
  40. package/web-ui/styles/titles-cards.css +2 -2
package/README.md CHANGED
@@ -49,7 +49,7 @@
49
49
 
50
50
  Have you ever felt overwhelmed by managing multiple local AI agents? Each has its own config format, session storage, and skills directory.
51
51
 
52
- **Codex Mate** offers a unified control plane to bring order to the chaos. It's a local-first CLI + Web UI designed to manage [Codex](https://github.com/openai/codex), [Claude Code](https://github.com/anthropic-ai/claude-code), [OpenCode](https://opencode.ai/), KiloCode, and [OpenClaw](https://github.com/moeru-ai/openclaw) seamlessly.
52
+ **Codex Mate** offers a unified control plane to bring order to the chaos. It's a local-first CLI + Web UI designed to manage [Codex](https://github.com/openai/codex), [Claude Code](https://github.com/anthropic-ai/claude-code), [OpenCode](https://opencode.ai/), KiloCode, [OpenClaw](https://github.com/moeru-ai/openclaw), and Pi seamlessly.
53
53
 
54
54
  ### What's So Special?
55
55
 
@@ -59,6 +59,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**:
59
59
  - **Claude Provider Bridge**: Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama through the built-in local Claude-compatible proxy.
60
60
  - **OpenCode Provider Control**: Manage OpenCode provider/model selection with a CodexMate-owned provider store under `~/.codexmate`, projecting only the active provider into native OpenCode config to avoid polluting or deleting user-owned settings.
61
61
  - **KiloCode Config Bridge**: Configure KiloCode providers from the CLI or Web UI, writing to `~/.config/kilo/kilo.jsonc` while preserving stored API keys.
62
+ - **Pi Provider Config**: Manage Pi providers from the Web UI with lossless round-trip writes to `~/.pi/agent/models.json`, auto-fetched remote model lists, and one-click default provider/model switching via `~/.pi/agent/settings.json`.
62
63
  - **Provider Health Cleanup**: Probe local Codex and Claude provider routes, surface failed configs in one modal, and bulk-clean selected broken providers without touching healthy or protected entries.
63
64
  - **Skills Marketplace**: A local-first market to share and import skills between different agent apps.
64
65
  - **Prompt File Editor**: Unified editor for global and project-level `CLAUDE.md` and `AGENTS.md` with auto-detection of project paths, plus a shared preset pool for reusable prompts.
@@ -70,7 +71,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**:
70
71
 
71
72
  | Feature | Status | Description |
72
73
  | --- | --- | --- |
73
- | **Provider Management** | ✅ | Switch providers/models for Codex, Claude, OpenCode, KiloCode, and OpenClaw |
74
+ | **Provider Management** | ✅ | Switch providers/models for Codex, Claude, OpenCode, KiloCode, OpenClaw, and Pi |
74
75
  | **Live Agent Sync** | ✅ | Real-time monitoring of Codex/Claude config & status |
75
76
  | **Session Browser** | ✅ | Search, preview, filter, and export sessions across Codex, Claude Code, Gemini CLI, CodeBuddy Code, and Pi |
76
77
  | **Usage Analytics** | ✅ | Visualize message trends and top projects |
@@ -80,6 +81,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**:
80
81
  | **Claude Provider Bridge** | ✅ | Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama via the built-in Claude-compatible proxy |
81
82
  | **OpenCode Provider Store** | ✅ | Keep multiple OpenCode providers in `~/.codexmate` while projecting only the selected provider to native OpenCode config |
82
83
  | **KiloCode Config Bridge** | ✅ | Configure KiloCode through `codexmate kilo config` or the Web UI, writing provider URL/model/API key data to `~/.config/kilo/kilo.jsonc` |
84
+ | **Pi Provider Config** | ✅ | Manage Pi providers in the Web UI writing to `~/.pi/agent/models.json` (unknown fields preserved) and switch the default provider/model in `~/.pi/agent/settings.json` |
83
85
  | **Provider Health Check** | ✅ | Probe local Codex/Claude provider routes, highlight failed configs, and bulk-remove selected broken providers safely |
84
86
  | **Prompt Templates** | ✅ | Reusable prompt plugins with variables |
85
87
  | **Prompt File Editor** | ✅ | Edit global and project-level CLAUDE.md / AGENTS.md with auto-detect, path switching, and a shared preset pool. Applying a preset only updates the editor; save manually to write the file. |
@@ -133,7 +135,7 @@ curl -fsSL https://raw.githubusercontent.com/SakuraByteCore/codexmate/main/scrip
133
135
  - **KiloCode**: `npm install -g @kilocode/cli` (`kilo` / `kilocode`)
134
136
  - **OpenCode**: install from the [official OpenCode docs](https://opencode.ai/)
135
137
  - **OpenClaw**: see the [OpenClaw repository](https://github.com/moeru-ai/openclaw)
136
- - **Pi**: sessions are discovered from `~/.pi/agent/sessions`
138
+ - **Pi**: sessions are discovered from `~/.pi/agent/sessions`; provider config lives in `~/.pi/agent/models.json` and the default provider/model in `~/.pi/agent/settings.json`
137
139
 
138
140
  ---
139
141
 
@@ -184,10 +186,6 @@ Special thanks to all contributors for their contributions to Codex Mate ❤️
184
186
  <img src="https://contrib.rocks/image?repo=SakuraByteCore/codexmate" />
185
187
  </a>
186
188
 
187
- ## Star History
188
-
189
- [![Star History Chart](https://api.star-history.com/svg?repos=SakuraByteCore/codexmate&type=Date)](https://star-history.com/#SakuraByteCore/codexmate&Date)
190
-
191
189
  ## License
192
190
 
193
191
  Apache-2.0
package/README.vi.md CHANGED
@@ -41,7 +41,7 @@
41
41
 
42
42
  Bạn có bao giờ cảm thấy rối khi phải quản lý nhiều AI agent cục bộ? Mỗi tool lại có định dạng cấu hình, nơi lưu phiên và thư mục skill riêng.
43
43
 
44
- **Codex Mate** cung cấp một trung tâm điều khiển thống nhất để giải quyết sự hỗn loạn đó. Đây là CLI + Web UI ưu tiên cục bộ, được thiết kế để quản lý [Codex](https://github.com/openai/codex), [Claude Code](https://github.com/anthropic-ai/claude-code), [OpenCode](https://opencode.ai/), KiloCode và [OpenClaw](https://github.com/moeru-ai/openclaw) một cách liền mạch.
44
+ **Codex Mate** cung cấp một trung tâm điều khiển thống nhất để giải quyết sự hỗn loạn đó. Đây là CLI + Web UI ưu tiên cục bộ, được thiết kế để quản lý [Codex](https://github.com/openai/codex), [Claude Code](https://github.com/anthropic-ai/claude-code), [OpenCode](https://opencode.ai/), KiloCode, [OpenClaw](https://github.com/moeru-ai/openclaw) và Pi một cách liền mạch.
45
45
 
46
46
  ### Điểm nổi bật
47
47
 
@@ -58,7 +58,8 @@ Khác với các wrapper đơn giản, Codex Mate hoạt động như một **Ag
58
58
 
59
59
  | Tính năng | Trạng thái | Mô tả |
60
60
  | --- | --- | --- |
61
- | **Quản lý Provider** | ✅ | Chuyển đổi provider/model cho Codex, Claude, OpenCode, KiloCode và OpenClaw |
61
+ | **Quản lý Provider** | ✅ | Chuyển đổi provider/model cho Codex, Claude, OpenCode, KiloCode, OpenClaw và Pi |
62
+ | **Cấu hình Provider Pi** | ✅ | Quản lý provider Pi trong Web UI, ghi vào `~/.pi/agent/models.json` (giữ nguyên các trường không nhận dạng được) và chuyển đổi provider/model mặc định trong `~/.pi/agent/settings.json` |
62
63
  | **Đồng bộ Agent trực tiếp** | ✅ | Giám sát cấu hình & trạng thái Codex/Claude theo thời gian thực |
63
64
  | **Trình duyệt phiên** | ✅ | Liệt kê, xem trước, lọc và xuất phiên (Codex/Claude/Gemini/CodeBuddy/Pi) |
64
65
  | **Phân tích sử dụng** | ✅ | Trực quan hóa xu hướng tin nhắn và dự án nổi bật |
@@ -118,7 +119,7 @@ curl -fsSL https://raw.githubusercontent.com/SakuraByteCore/codexmate/main/scrip
118
119
  - **KiloCode**: `npm install -g @kilocode/cli` (`kilo` / `kilocode`)
119
120
  - **OpenCode**: xem [tài liệu chính thức OpenCode](https://opencode.ai/)
120
121
  - **OpenClaw**: xem [kho lưu trữ OpenClaw](https://github.com/moeru-ai/openclaw)
121
- - **Pi**: phiên làm việc được phát hiện từ `~/.pi/agent/sessions`
122
+ - **Pi**: phiên làm việc được phát hiện từ `~/.pi/agent/sessions`; cấu hình provider nằm ở `~/.pi/agent/models.json`, provider/model mặc định nằm ở `~/.pi/agent/settings.json`
122
123
 
123
124
  ---
124
125
 
@@ -167,10 +168,6 @@ Cảm ơn tất cả những người đóng góp cho Codex Mate ❤️
167
168
  <img src="https://contrib.rocks/image?repo=SakuraByteCore/codexmate" alt="Contributors" />
168
169
  </a>
169
170
 
170
- ## Lịch sử Star
171
-
172
- [![Star History Chart](https://api.star-history.com/svg?repos=SakuraByteCore/codexmate&type=Date)](https://star-history.com/#SakuraByteCore/codexmate&Date)
173
-
174
171
  ## Giấy phép
175
172
 
176
173
  Apache-2.0
package/README.zh.md CHANGED
@@ -48,7 +48,7 @@
48
48
 
49
49
  你是否曾因管理多个本地 AI 智能体而感到疲惫?每个工具都有自己的配置格式、会话存储和 Skills 目录。
50
50
 
51
- **Codex Mate** 提供了一个统一的控制平面,让混乱重归有序。这是一个本地优先的 CLI + Web UI,旨在无缝管理 [Codex](https://github.com/openai/codex)、[Claude Code](https://github.com/anthropic-ai/claude-code)、[OpenCode](https://opencode.ai/)、KiloCode 和 [OpenClaw](https://github.com/moeru-ai/openclaw)。
51
+ **Codex Mate** 提供了一个统一的控制平面,让混乱重归有序。这是一个本地优先的 CLI + Web UI,旨在无缝管理 [Codex](https://github.com/openai/codex)、[Claude Code](https://github.com/anthropic-ai/claude-code)、[OpenCode](https://opencode.ai/)、KiloCode、[OpenClaw](https://github.com/moeru-ai/openclaw) 与 Pi。
52
52
 
53
53
  ### 有什么独特之处?
54
54
 
@@ -57,6 +57,7 @@
57
57
  - **OpenAI 兼容桥接**:通过归一化 Responses API,让 Codex 能够与任何支持 OpenAI 格式的 UI 配合使用;内建 Codex 转换会补齐并规范化 `User-Agent`、`Version`、`OpenAI-Beta`、`Originator` 等 Codex 指纹,对上游伪装为官方 Codex CLI 请求。
58
58
  - **Claude Provider 桥接**:通过内建本地 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama。
59
59
  - **OpenCode Provider 控制**:在 `~/.codexmate` 下维护 CodexMate 自有的 OpenCode 多 provider 存储,只将当前选中的 provider 投影到 OpenCode 原生配置,避免污染或误删用户已有配置。
60
+ - **Pi Provider 配置**:在 Web UI 中管理 Pi 供应商,写入 `~/.pi/agent/models.json` 时无损保留未知字段,支持自动拉取远端模型列表,并可一键切换 `~/.pi/agent/settings.json` 中的默认供应商/模型。
60
61
  - **Provider 健康清理**:探测本地 Codex 与 Claude provider 路由,在同一个弹窗里集中展示失败配置,并批量清理已选坏配置,不影响正常或受保护项。
61
62
  - **Skills 市场**:本地优先的市场,支持在不同的智能体应用之间共享和导入 Skills。
62
63
  - **提示词文件编辑器**:统一编辑全局和项目级 `CLAUDE.md` 与 `AGENTS.md`,支持项目路径自动检测,并提供通用预设池复用 prompt。
@@ -68,7 +69,7 @@
68
69
 
69
70
  | 特性 | 状态 | 描述 |
70
71
  | --- | --- | --- |
71
- | **Provider 管理** | ✅ | 切换 Codex、Claude、OpenCode、KiloCode 和 OpenClaw 的 provider/model |
72
+ | **Provider 管理** | ✅ | 切换 Codex、Claude、OpenCode、KiloCode、OpenClaw 与 Pi 的 provider/model |
72
73
  | **状态实时同步** | ✅ | 实时感知 Codex/Claude 的配置与运行状态变更 |
73
74
  | **会话浏览器** | ✅ | 跨 Codex、Claude Code、Gemini CLI、CodeBuddy Code 与 Pi 的本地会话进行检索、预览、筛选与导出 |
74
75
  | **Usage 统计** | ✅ | 可视化消息趋势与热门项目统计 |
@@ -77,6 +78,7 @@
77
78
  | **OpenAI 桥接** | ✅ | 将 Codex Responses API 转换为标准 OpenAI 格式,并在内建转换中附加/规范化 Codex 指纹 |
78
79
  | **Claude Provider 桥接** | ✅ | 通过内建 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama |
79
80
  | **OpenCode Provider 存储** | ✅ | 在 `~/.codexmate` 中保留多个 OpenCode provider,只将当前选中的 provider 投影到 OpenCode 原生配置 |
81
+ | **Pi Provider 配置** | ✅ | 在 Web UI 中管理 Pi 供应商,写入 `~/.pi/agent/models.json`(未知字段无损保留),并切换 `~/.pi/agent/settings.json` 中的默认供应商/模型 |
80
82
  | **Provider 健康检查** | ✅ | 探测本地 Codex/Claude provider 路由,标记失败配置,并安全批量移除已选坏配置 |
81
83
  | **提示词模板** | ✅ | 支持变量的可复用提示词插件 |
82
84
  | **提示词文件编辑器** | ✅ | 编辑全局和项目级 CLAUDE.md / AGENTS.md,支持自动检测、路径切换与通用预设池。应用预设只更新编辑器,需手动保存才会写入文件。 |
@@ -130,7 +132,7 @@ curl -fsSL https://raw.githubusercontent.com/SakuraByteCore/codexmate/main/scrip
130
132
  - **KiloCode**: `npm install -g @kilocode/cli`(`kilo` / `kilocode`)
131
133
  - **OpenCode**: 参照 [OpenCode 官方文档](https://opencode.ai/) 安装
132
134
  - **OpenClaw**: 参见 [OpenClaw 仓库](https://github.com/moeru-ai/openclaw)
133
- - **Pi**: 会话从 `~/.pi/agent/sessions` 自动发现
135
+ - **Pi**: 会话从 `~/.pi/agent/sessions` 自动发现;供应商配置位于 `~/.pi/agent/models.json`,默认供应商/模型位于 `~/.pi/agent/settings.json`
134
136
 
135
137
  ---
136
138
 
@@ -181,10 +183,6 @@ flowchart TD
181
183
  <img src="https://contrib.rocks/image?repo=SakuraByteCore/codexmate" />
182
184
  </a>
183
185
 
184
- ## Star 历史
185
-
186
- [![Star History Chart](https://api.star-history.com/svg?repos=SakuraByteCore/codexmate&type=Date)](https://star-history.com/#SakuraByteCore/codexmate&Date)
187
-
188
186
  ## 开源协议
189
187
 
190
188
  Apache-2.0
@@ -14,7 +14,8 @@ function createAgentsFileController(deps = {}) {
14
14
  CLAUDE_DIR,
15
15
  CLAUDE_MD_FILE_NAME,
16
16
  readOpenclawAgentsFile,
17
- readOpenclawWorkspaceFile
17
+ readOpenclawWorkspaceFile,
18
+ backupPromptBeforeWrite
18
19
  } = deps;
19
20
 
20
21
  if (!fs) throw new Error('createAgentsFileController 缺少 fs');
@@ -32,6 +33,7 @@ function createAgentsFileController(deps = {}) {
32
33
  if (typeof CLAUDE_MD_FILE_NAME !== 'string' || !CLAUDE_MD_FILE_NAME) throw new Error('createAgentsFileController 缺少 CLAUDE_MD_FILE_NAME');
33
34
  if (typeof readOpenclawAgentsFile !== 'function') throw new Error('createAgentsFileController 缺少 readOpenclawAgentsFile');
34
35
  if (typeof readOpenclawWorkspaceFile !== 'function') throw new Error('createAgentsFileController 缺少 readOpenclawWorkspaceFile');
36
+ if (typeof backupPromptBeforeWrite !== 'function' && typeof backupPromptBeforeWrite !== 'undefined') throw new Error('createAgentsFileController 备份回调无效');
35
37
 
36
38
  function resolveAgentsFilePath(params = {}) {
37
39
  const baseDir = typeof params.baseDir === 'string' && params.baseDir.trim()
@@ -40,6 +42,12 @@ function createAgentsFileController(deps = {}) {
40
42
  return path.join(baseDir, AGENTS_FILE_NAME);
41
43
  }
42
44
 
45
+ function sanitizeHistoryId(raw) {
46
+ const safe = typeof raw === 'string' ? raw.trim() : '';
47
+ if (!safe) return 'global';
48
+ return safe.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 64) || 'global';
49
+ }
50
+
43
51
  function validateAgentsBaseDir(filePath) {
44
52
  const dirPath = path.dirname(filePath);
45
53
  try {
@@ -152,6 +160,13 @@ function createAgentsFileController(deps = {}) {
152
160
  if (content.length > 2 * 1024 * 1024) {
153
161
  return { error: 'content too large (max 2MB)' };
154
162
  }
163
+ if (typeof backupPromptBeforeWrite === 'function') {
164
+ var bucket = resolved.isProject
165
+ ? 'claude-project_' + sanitizeHistoryId(resolved.projectPath)
166
+ : 'claude-global';
167
+ backupPromptBeforeWrite(bucket, filePath);
168
+ resolved.historyBucket = bucket;
169
+ }
155
170
  var lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
156
171
  var normalized = normalizeLineEnding(content, lineEnding);
157
172
  var finalContent = ensureUtf8Bom(normalized);
@@ -159,6 +174,7 @@ function createAgentsFileController(deps = {}) {
159
174
  ensureDir(path.dirname(filePath));
160
175
  fs.writeFileSync(filePath, finalContent, 'utf-8');
161
176
  var result = { success: true, path: filePath };
177
+ if (resolved.historyBucket) result.historyBucket = resolved.historyBucket;
162
178
  if (resolved.isProject) {
163
179
  result.projectPath = resolved.projectPath;
164
180
  result.detectionSource = resolved.detectionSource;
@@ -218,13 +234,21 @@ function createAgentsFileController(deps = {}) {
218
234
  if (content.length > 2 * 1024 * 1024) {
219
235
  return { error: '内容过大(最大 2MB)' };
220
236
  }
237
+ let agentsHistoryBucket = '';
238
+ if (typeof backupPromptBeforeWrite === 'function') {
239
+ const bucket = 'codex_' + sanitizeHistoryId(String(params.baseDir || '').trim() || 'global');
240
+ backupPromptBeforeWrite(bucket, filePath);
241
+ agentsHistoryBucket = bucket;
242
+ }
221
243
  const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
222
- const normalized = normalizeLineEnding(content, lineEnding);
244
+ var normalized = normalizeLineEnding(content, lineEnding);
223
245
  const finalContent = ensureUtf8Bom(normalized);
224
246
 
225
247
  try {
226
248
  fs.writeFileSync(filePath, finalContent, 'utf-8');
227
- return { success: true, path: filePath };
249
+ const agentsResult = { success: true, path: filePath };
250
+ if (agentsHistoryBucket) agentsResult.historyBucket = agentsHistoryBucket;
251
+ return agentsResult;
228
252
  } catch (e) {
229
253
  return { error: `写入 AGENTS.md 失败: ${e.message}` };
230
254
  }
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ function createPromptHistoryController(deps = {}) {
4
+ const {
5
+ fs,
6
+ path,
7
+ CONFIG_DIR,
8
+ MAX_HISTORY = 20
9
+ } = deps;
10
+
11
+ if (!fs) throw new Error('createPromptHistoryController 缺少 fs');
12
+ if (!path) throw new Error('createPromptHistoryController 缺少 path');
13
+ if (typeof CONFIG_DIR !== 'string' || !CONFIG_DIR) throw new Error('createPromptHistoryController 缺少 CONFIG_DIR');
14
+
15
+ const HISTORY_ROOT = path.join(CONFIG_DIR, 'codexmate-prompt-history');
16
+ const STAMP_RE = /^(\d{8})-(\d{6})-(\d{3})\.bak$/;
17
+
18
+ function sanitizeBucket(bucket) {
19
+ const raw = typeof bucket === 'string' ? bucket.trim() : '';
20
+ if (!raw) throw new Error('prompt history bucket 不能为空');
21
+ const safe = raw.replace(/[^A-Za-z0-9_.-]/g, '_');
22
+ if (!safe) throw new Error('prompt history bucket 无效: ' + bucket);
23
+ return safe;
24
+ }
25
+
26
+ function resolveBucketDir(bucket) {
27
+ const safe = sanitizeBucket(bucket);
28
+ return path.join(HISTORY_ROOT, safe);
29
+ }
30
+
31
+ function buildTimestamp(d = new Date()) {
32
+ const pad2 = (n) => String(n).padStart(2, '0');
33
+ const pad3 = (n) => String(n).padStart(3, '0');
34
+ return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}`
35
+ + `-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
36
+ + `-${pad3(d.getMilliseconds())}`;
37
+ }
38
+
39
+ function parseStamp(name) {
40
+ return STAMP_RE.test(name) ? name.slice(0, name.length - 4) : null;
41
+ }
42
+
43
+ function ensureDirSync(dir) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ }
46
+
47
+ function rmtreeSync(dir) {
48
+ try {
49
+ fs.rmSync(dir, { recursive: true, force: true });
50
+ } catch (_) {}
51
+ }
52
+
53
+ function backupPromptBeforeWrite(bucket, currentFilePath) {
54
+ const dir = resolveBucketDir(bucket);
55
+ const exists = fs.existsSync(currentFilePath);
56
+ if (!exists) return { backedUp: false, entries: listPromptHistory(bucket) };
57
+ let raw;
58
+ try {
59
+ raw = fs.readFileSync(currentFilePath, 'utf-8');
60
+ } catch (e) {
61
+ return { backedUp: false, error: '读取当前文件失败: ' + e.message };
62
+ }
63
+ ensureDirSync(dir);
64
+ const stamp = buildTimestamp();
65
+ const dest = path.join(dir, stamp + '.bak');
66
+ try {
67
+ fs.writeFileSync(dest, raw, 'utf-8');
68
+ } catch (e) {
69
+ return { backedUp: false, error: '写入备份失败: ' + e.message };
70
+ }
71
+ trimHistory(dir);
72
+ return { backedUp: true, entries: listPromptHistory(bucket) };
73
+ }
74
+
75
+ function listPromptHistory(bucket) {
76
+ const dir = resolveBucketDir(bucket);
77
+ if (!fs.existsSync(dir)) return [];
78
+ let names;
79
+ try {
80
+ names = fs.readdirSync(dir);
81
+ } catch (_) {
82
+ return [];
83
+ }
84
+ const items = [];
85
+ for (const name of names) {
86
+ const stamp = parseStamp(name);
87
+ if (!stamp) continue;
88
+ let stat;
89
+ try {
90
+ stat = fs.statSync(path.join(dir, name));
91
+ } catch (_) {
92
+ continue;
93
+ }
94
+ items.push({
95
+ id: stamp,
96
+ bucket: sanitizeBucket(bucket),
97
+ size: Number(stat.size) || 0,
98
+ mtimeMs: Number(stat.mtimeMs) || 0
99
+ });
100
+ }
101
+ items.sort((a, b) => b.mtimeMs - a.mtimeMs);
102
+ return items;
103
+ }
104
+
105
+ function readPromptHistory(bucket, id) {
106
+ const dir = resolveBucketDir(bucket);
107
+ const stamp = typeof id === 'string' ? id.trim() : '';
108
+ const fullName = stamp + '.bak';
109
+ if (!stamp || !STAMP_RE.test(fullName)) {
110
+ return { error: 'history id invalid' };
111
+ }
112
+ const file = path.join(dir, fullName);
113
+ if (!fs.existsSync(file)) return { error: 'history entry 不存在' };
114
+ try {
115
+ const content = fs.readFileSync(file, 'utf-8');
116
+ return { id: stamp, content, bucket: sanitizeBucket(bucket) };
117
+ } catch (e) {
118
+ return { error: '读取 history 失败: ' + e.message };
119
+ }
120
+ }
121
+
122
+ function trimHistory(dir) {
123
+ let names;
124
+ try {
125
+ names = fs.readdirSync(dir);
126
+ } catch (_) {
127
+ return;
128
+ }
129
+ const stamped = [];
130
+ for (const name of names) {
131
+ const stamp = parseStamp(name);
132
+ if (!stamp) continue;
133
+ let stat;
134
+ try {
135
+ stat = fs.statSync(path.join(dir, name));
136
+ } catch (_) {
137
+ continue;
138
+ }
139
+ stamped.push({ name, mtimeMs: Number(stat.mtimeMs) || 0 });
140
+ }
141
+ stamped.sort((a, b) => b.mtimeMs - a.mtimeMs);
142
+ if (stamped.length <= MAX_HISTORY) return;
143
+ for (let i = MAX_HISTORY; i < stamped.length; i++) {
144
+ try {
145
+ fs.unlinkSync(path.join(dir, stamped[i].name));
146
+ } catch (_) {}
147
+ }
148
+ }
149
+
150
+ function clearPromptHistory(bucket) {
151
+ if (!bucket) {
152
+ rmtreeSync(HISTORY_ROOT);
153
+ return { cleared: true };
154
+ }
155
+ const dir = resolveBucketDir(bucket);
156
+ rmtreeSync(dir);
157
+ return { cleared: true };
158
+ }
159
+
160
+ return {
161
+ sanitizeBucket,
162
+ backupPromptBeforeWrite,
163
+ listPromptHistory,
164
+ readPromptHistory,
165
+ clearPromptHistory
166
+ };
167
+ }
168
+
169
+ module.exports = {
170
+ createPromptHistoryController
171
+ };
@@ -8,7 +8,8 @@ function createSystemPromptFileController(deps = {}) {
8
8
  crypto,
9
9
  buildLineDiff,
10
10
  CONFIG_DIR,
11
- PI_AGENT_DIR
11
+ PI_AGENT_DIR,
12
+ backupPromptBeforeWrite
12
13
  } = deps;
13
14
 
14
15
  if (!fs) throw new Error('createSystemPromptFileController 缺少 fs');
@@ -18,6 +19,7 @@ function createSystemPromptFileController(deps = {}) {
18
19
  if (typeof buildLineDiff !== 'function') throw new Error('createSystemPromptFileController 缺少 buildLineDiff');
19
20
  if (typeof CONFIG_DIR !== 'string' || !CONFIG_DIR) throw new Error('createSystemPromptFileController 缺少 CONFIG_DIR');
20
21
  if (typeof PI_AGENT_DIR !== 'string' || !PI_AGENT_DIR) throw new Error('createSystemPromptFileController 缺少 PI_AGENT_DIR');
22
+ if (typeof backupPromptBeforeWrite !== 'function' && typeof backupPromptBeforeWrite !== 'undefined') throw new Error('createSystemPromptFileController 备份回调无效');
21
23
 
22
24
  const MODES = {
23
25
  system: 'SYSTEM.md',
@@ -34,6 +36,12 @@ function createSystemPromptFileController(deps = {}) {
34
36
  return key;
35
37
  }
36
38
 
39
+ function sanitizeSystemHistoryId(raw) {
40
+ const safe = typeof raw === 'string' ? raw.trim() : '';
41
+ if (!safe) return 'global';
42
+ return safe.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 64) || 'global';
43
+ }
44
+
37
45
  function normalizeScope(scope) {
38
46
  const key = typeof scope === 'string' ? scope.trim() : '';
39
47
  if (!SCOPES.includes(key)) {
@@ -104,16 +112,26 @@ function createSystemPromptFileController(deps = {}) {
104
112
  if (baseHash && baseHash !== current.hash) {
105
113
  return { error: '文件已被外部修改,请重新加载后再保存' };
106
114
  }
115
+ let sysHistoryBucket = '';
107
116
  try {
108
117
  const dir = path.dirname(target.path);
109
118
  fs.mkdirSync(dir, { recursive: true });
119
+ if (typeof backupPromptBeforeWrite === 'function') {
120
+ const bucket = 'system_' + target.scope + '_' + (target.scope === 'project'
121
+ ? sanitizeSystemHistoryId(path.dirname(target.path))
122
+ : 'global');
123
+ backupPromptBeforeWrite(bucket, target.path);
124
+ sysHistoryBucket = bucket;
125
+ }
110
126
  const finalContent = content.endsWith('\n') ? content : content + '\n';
111
127
  fs.writeFileSync(target.path, finalContent, { encoding: 'utf8', mode: 0o600 });
112
128
  try { fs.chmodSync(target.path, 0o600); } catch (_) {}
113
129
  } catch (e) {
114
130
  return { error: '写入 system prompt 失败: ' + e.message };
115
131
  }
116
- return readSystemPromptFilePath(target);
132
+ const saved = readSystemPromptFilePath(target);
133
+ if (sysHistoryBucket) saved.historyBucket = sysHistoryBucket;
134
+ return saved;
117
135
  }
118
136
 
119
137
  function readSystemPromptFilePath(resolved) {