Skip to content

Commit df2c298

Browse files
committed
feat(prompts): add per-subtab history for instruction file editor
- Add backend prompt history controller (write-before-overwrite .bak, rolling cap 20, path-injection guard) wired into all three apply paths (AGENTS.md, CLAUDE.md, System Prompt) without touching optimistic lock, scroll-to-top, or request-token guards - Expose list-prompt-history / get-prompt-history RPC actions - Add symmetric history drawers in the three instruction file subtabs (codex / claude-project / system) with list, preview, and restore-to-editor semantics (restore only fills the editor; save is still required to write) - Cache authoritative historyBucket from each successful save - Add i18n keys across en/ja/vi/zh/zh-tw and regenerate precompiled render - Bump version to 0.1.11
1 parent 27479dd commit df2c298

16 files changed

Lines changed: 751 additions & 7 deletions

cli.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ const {
108108
const {
109109
createSystemPromptFileController
110110
} = require('./cli/system-prompt-files');
111+
const {
112+
createPromptHistoryController
113+
} = require('./cli/prompt-history');
111114
const {
112115
createArchiveHelperController
113116
} = require('./cli/archive-helpers');
@@ -2057,6 +2060,17 @@ async function fetchProviderModels(providerName, overrides = {}) {
20572060
}
20582061

20592062
// buildAgentsDiff keeps the metaOnly optimization inside cli/agents-files.js.
2063+
const {
2064+
backupPromptBeforeWrite: historyBackup,
2065+
listPromptHistory,
2066+
readPromptHistory,
2067+
clearPromptHistory
2068+
} = createPromptHistoryController({
2069+
fs,
2070+
path,
2071+
CONFIG_DIR
2072+
});
2073+
20602074
const {
20612075
resolveAgentsFilePath,
20622076
validateAgentsBaseDir,
@@ -2083,6 +2097,7 @@ const {
20832097
AGENTS_FILE_NAME,
20842098
CLAUDE_DIR,
20852099
CLAUDE_MD_FILE_NAME,
2100+
backupPromptBeforeWrite: historyBackup,
20862101
readOpenclawAgentsFile() {
20872102
return readOpenclawAgentsFile(...arguments);
20882103
},
@@ -2102,7 +2117,8 @@ const {
21022117
crypto,
21032118
buildLineDiff,
21042119
CONFIG_DIR,
2105-
PI_AGENT_DIR
2120+
PI_AGENT_DIR,
2121+
backupPromptBeforeWrite: historyBackup
21062122
});
21072123

21082124
const {
@@ -13378,6 +13394,12 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
1337813394
case 'preview-system-prompt-diff':
1337913395
result = buildSystemPromptDiff(params || {});
1338013396
break;
13397+
case 'list-prompt-history':
13398+
result = listPromptHistory((params && params.bucket) || '');
13399+
break;
13400+
case 'get-prompt-history':
13401+
result = readPromptHistory((params && params.bucket) || '', (params && params.id) || '');
13402+
break;
1338113403
case 'switch':
1338213404
case 'use':
1338313405
case 'add':

cli/agents-files.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ function createAgentsFileController(deps = {}) {
1414
CLAUDE_DIR,
1515
CLAUDE_MD_FILE_NAME,
1616
readOpenclawAgentsFile,
17-
readOpenclawWorkspaceFile
17+
readOpenclawWorkspaceFile,
18+
backupPromptBeforeWrite
1819
} = deps;
1920

2021
if (!fs) throw new Error('createAgentsFileController 缺少 fs');
@@ -32,6 +33,7 @@ function createAgentsFileController(deps = {}) {
3233
if (typeof CLAUDE_MD_FILE_NAME !== 'string' || !CLAUDE_MD_FILE_NAME) throw new Error('createAgentsFileController 缺少 CLAUDE_MD_FILE_NAME');
3334
if (typeof readOpenclawAgentsFile !== 'function') throw new Error('createAgentsFileController 缺少 readOpenclawAgentsFile');
3435
if (typeof readOpenclawWorkspaceFile !== 'function') throw new Error('createAgentsFileController 缺少 readOpenclawWorkspaceFile');
36+
if (typeof backupPromptBeforeWrite !== 'function' && typeof backupPromptBeforeWrite !== 'undefined') throw new Error('createAgentsFileController 备份回调无效');
3537

3638
function resolveAgentsFilePath(params = {}) {
3739
const baseDir = typeof params.baseDir === 'string' && params.baseDir.trim()
@@ -40,6 +42,12 @@ function createAgentsFileController(deps = {}) {
4042
return path.join(baseDir, AGENTS_FILE_NAME);
4143
}
4244

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+
4351
function validateAgentsBaseDir(filePath) {
4452
const dirPath = path.dirname(filePath);
4553
try {
@@ -152,13 +160,21 @@ function createAgentsFileController(deps = {}) {
152160
if (content.length > 2 * 1024 * 1024) {
153161
return { error: 'content too large (max 2MB)' };
154162
}
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+
}
155170
var lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
156171
var normalized = normalizeLineEnding(content, lineEnding);
157172
var finalContent = ensureUtf8Bom(normalized);
158173
try {
159174
ensureDir(path.dirname(filePath));
160175
fs.writeFileSync(filePath, finalContent, 'utf-8');
161176
var result = { success: true, path: filePath };
177+
if (resolved.historyBucket) result.historyBucket = resolved.historyBucket;
162178
if (resolved.isProject) {
163179
result.projectPath = resolved.projectPath;
164180
result.detectionSource = resolved.detectionSource;
@@ -218,13 +234,21 @@ function createAgentsFileController(deps = {}) {
218234
if (content.length > 2 * 1024 * 1024) {
219235
return { error: '内容过大(最大 2MB)' };
220236
}
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+
}
221243
const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
222-
const normalized = normalizeLineEnding(content, lineEnding);
244+
var normalized = normalizeLineEnding(content, lineEnding);
223245
const finalContent = ensureUtf8Bom(normalized);
224246

225247
try {
226248
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;
228252
} catch (e) {
229253
return { error: `写入 AGENTS.md 失败: ${e.message}` };
230254
}

cli/prompt-history.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
};

cli/system-prompt-files.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ function createSystemPromptFileController(deps = {}) {
88
crypto,
99
buildLineDiff,
1010
CONFIG_DIR,
11-
PI_AGENT_DIR
11+
PI_AGENT_DIR,
12+
backupPromptBeforeWrite
1213
} = deps;
1314

1415
if (!fs) throw new Error('createSystemPromptFileController 缺少 fs');
@@ -18,6 +19,7 @@ function createSystemPromptFileController(deps = {}) {
1819
if (typeof buildLineDiff !== 'function') throw new Error('createSystemPromptFileController 缺少 buildLineDiff');
1920
if (typeof CONFIG_DIR !== 'string' || !CONFIG_DIR) throw new Error('createSystemPromptFileController 缺少 CONFIG_DIR');
2021
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 备份回调无效');
2123

2224
const MODES = {
2325
system: 'SYSTEM.md',
@@ -34,6 +36,12 @@ function createSystemPromptFileController(deps = {}) {
3436
return key;
3537
}
3638

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+
3745
function normalizeScope(scope) {
3846
const key = typeof scope === 'string' ? scope.trim() : '';
3947
if (!SCOPES.includes(key)) {
@@ -104,16 +112,26 @@ function createSystemPromptFileController(deps = {}) {
104112
if (baseHash && baseHash !== current.hash) {
105113
return { error: '文件已被外部修改,请重新加载后再保存' };
106114
}
115+
let sysHistoryBucket = '';
107116
try {
108117
const dir = path.dirname(target.path);
109118
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+
}
110126
const finalContent = content.endsWith('\n') ? content : content + '\n';
111127
fs.writeFileSync(target.path, finalContent, { encoding: 'utf8', mode: 0o600 });
112128
try { fs.chmodSync(target.path, 0o600); } catch (_) {}
113129
} catch (e) {
114130
return { error: '写入 system prompt 失败: ' + e.message };
115131
}
116-
return readSystemPromptFilePath(target);
132+
const saved = readSystemPromptFilePath(target);
133+
if (sysHistoryBucket) saved.historyBucket = sysHistoryBucket;
134+
return saved;
117135
}
118136

119137
function readSystemPromptFilePath(resolved) {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexmate",
3-
"version": "0.1.10",
3+
"version": "0.1.11",
44
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
55
"main": "cli.js",
66
"bin": {

0 commit comments

Comments
 (0)