principles-disciple 1.5.4 → 1.6.0
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.
- package/dist/commands/context.d.ts +5 -0
- package/dist/commands/context.js +308 -0
- package/dist/commands/focus.d.ts +14 -0
- package/dist/commands/focus.js +579 -0
- package/dist/commands/pain.js +135 -6
- package/dist/commands/rollback.d.ts +19 -0
- package/dist/commands/rollback.js +119 -0
- package/dist/core/config.d.ts +32 -0
- package/dist/core/config.js +47 -0
- package/dist/core/event-log.d.ts +21 -1
- package/dist/core/event-log.js +316 -0
- package/dist/core/focus-history.d.ts +65 -0
- package/dist/core/focus-history.js +266 -0
- package/dist/core/init.js +30 -7
- package/dist/core/migration.js +0 -2
- package/dist/core/path-resolver.d.ts +3 -0
- package/dist/core/path-resolver.js +20 -0
- package/dist/hooks/gate.js +203 -1
- package/dist/hooks/llm.d.ts +8 -0
- package/dist/hooks/llm.js +234 -1
- package/dist/hooks/message-sanitize.d.ts +3 -0
- package/dist/hooks/message-sanitize.js +37 -0
- package/dist/hooks/prompt.d.ts +12 -0
- package/dist/hooks/prompt.js +309 -135
- package/dist/hooks/subagent.d.ts +9 -2
- package/dist/hooks/subagent.js +13 -2
- package/dist/i18n/commands.js +32 -20
- package/dist/index.js +181 -4
- package/dist/service/empathy-observer-manager.d.ts +42 -0
- package/dist/service/empathy-observer-manager.js +147 -0
- package/dist/service/evolution-worker.d.ts +1 -0
- package/dist/service/evolution-worker.js +4 -2
- package/dist/tools/deep-reflect.js +80 -0
- package/dist/types/event-types.d.ts +77 -2
- package/dist/types/event-types.js +33 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +19 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -3
- package/templates/langs/zh/core/HEARTBEAT.md +28 -4
- package/templates/pain_settings.json +54 -2
- package/templates/workspace/.principles/PROFILE.json +2 -0
- package/templates/workspace/okr/CURRENT_FOCUS.md +57 -0
package/dist/core/event-log.js
CHANGED
|
@@ -57,6 +57,9 @@ export class EventLog {
|
|
|
57
57
|
recordTrustChange(sessionId, data) {
|
|
58
58
|
this.record('trust_change', 'changed', sessionId, data);
|
|
59
59
|
}
|
|
60
|
+
recordEmpathyRollback(sessionId, data) {
|
|
61
|
+
this.record('empathy_rollback', 'rolled_back', sessionId, data);
|
|
62
|
+
}
|
|
60
63
|
recordError(sessionId, message, context) {
|
|
61
64
|
this.record('error', 'failure', sessionId, { message, ...context });
|
|
62
65
|
}
|
|
@@ -115,6 +118,45 @@ export class EventLog {
|
|
|
115
118
|
const data = entry.data;
|
|
116
119
|
stats.pain.signalsDetected++;
|
|
117
120
|
stats.pain.maxScore = Math.max(stats.pain.maxScore, data.score);
|
|
121
|
+
// Update empathy stats for user_empathy source
|
|
122
|
+
if (data.source === 'user_empathy') {
|
|
123
|
+
if (data.deduped) {
|
|
124
|
+
stats.empathy.dedupedCount++;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
stats.empathy.totalEvents++;
|
|
128
|
+
stats.empathy.totalPenaltyScore += data.score || 0;
|
|
129
|
+
// By severity
|
|
130
|
+
if (data.severity) {
|
|
131
|
+
stats.empathy.bySeverity[data.severity]++;
|
|
132
|
+
stats.empathy.scoreBySeverity[data.severity] += data.score || 0;
|
|
133
|
+
}
|
|
134
|
+
// By detection mode
|
|
135
|
+
if (data.detection_mode) {
|
|
136
|
+
stats.empathy.byDetectionMode[data.detection_mode]++;
|
|
137
|
+
}
|
|
138
|
+
// By origin
|
|
139
|
+
if (data.origin) {
|
|
140
|
+
stats.empathy.byOrigin[data.origin]++;
|
|
141
|
+
}
|
|
142
|
+
// Confidence distribution
|
|
143
|
+
const conf = data.confidence ?? 1;
|
|
144
|
+
if (conf >= 0.8)
|
|
145
|
+
stats.empathy.confidenceDistribution.high++;
|
|
146
|
+
else if (conf >= 0.5)
|
|
147
|
+
stats.empathy.confidenceDistribution.medium++;
|
|
148
|
+
else
|
|
149
|
+
stats.empathy.confidenceDistribution.low++;
|
|
150
|
+
}
|
|
151
|
+
// Update dedupe hit rate
|
|
152
|
+
const total = stats.empathy.totalEvents + stats.empathy.dedupedCount;
|
|
153
|
+
stats.empathy.dedupeHitRate = total > 0 ? stats.empathy.dedupedCount / total : 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (entry.type === 'empathy_rollback') {
|
|
157
|
+
const data = entry.data;
|
|
158
|
+
stats.empathy.rollbackCount++;
|
|
159
|
+
stats.empathy.rolledBackScore += data.originalScore || 0;
|
|
118
160
|
}
|
|
119
161
|
}
|
|
120
162
|
startFlushTimer() {
|
|
@@ -164,6 +206,280 @@ export class EventLog {
|
|
|
164
206
|
}
|
|
165
207
|
return stats;
|
|
166
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Get aggregated empathy statistics for multiple time ranges.
|
|
211
|
+
* @param range 'today' | 'week' | 'session'
|
|
212
|
+
* @param sessionId Optional session ID for session-scoped stats
|
|
213
|
+
*/
|
|
214
|
+
getEmpathyStats(range, sessionId) {
|
|
215
|
+
const now = new Date();
|
|
216
|
+
const today = this.formatDate(now);
|
|
217
|
+
// Aggregate stats based on range
|
|
218
|
+
const result = {
|
|
219
|
+
totalEvents: 0,
|
|
220
|
+
dedupedCount: 0,
|
|
221
|
+
dedupeHitRate: 0,
|
|
222
|
+
totalPenaltyScore: 0,
|
|
223
|
+
rolledBackScore: 0,
|
|
224
|
+
rollbackCount: 0,
|
|
225
|
+
bySeverity: { mild: 0, moderate: 0, severe: 0 },
|
|
226
|
+
scoreBySeverity: { mild: 0, moderate: 0, severe: 0 },
|
|
227
|
+
byDetectionMode: { structured: 0, legacy_tag: 0 },
|
|
228
|
+
byOrigin: { assistant_self_report: 0, user_manual: 0, system_infer: 0 },
|
|
229
|
+
confidenceDistribution: { high: 0, medium: 0, low: 0 },
|
|
230
|
+
dailyTrend: [],
|
|
231
|
+
};
|
|
232
|
+
if (range === 'session' && sessionId) {
|
|
233
|
+
// For session range, scan event buffer and events file
|
|
234
|
+
this.aggregateSessionEmpathy(sessionId, result);
|
|
235
|
+
}
|
|
236
|
+
else if (range === 'week') {
|
|
237
|
+
// For week range, aggregate last 7 days
|
|
238
|
+
for (let i = 0; i < 7; i++) {
|
|
239
|
+
const date = new Date(now);
|
|
240
|
+
date.setDate(date.getDate() - i);
|
|
241
|
+
const dateStr = this.formatDate(date);
|
|
242
|
+
const stats = this.getDailyStats(dateStr);
|
|
243
|
+
result.totalEvents += stats.empathy.totalEvents;
|
|
244
|
+
result.dedupedCount += stats.empathy.dedupedCount;
|
|
245
|
+
result.totalPenaltyScore += stats.empathy.totalPenaltyScore;
|
|
246
|
+
result.rolledBackScore += stats.empathy.rolledBackScore;
|
|
247
|
+
result.rollbackCount += stats.empathy.rollbackCount;
|
|
248
|
+
for (const sev of ['mild', 'moderate', 'severe']) {
|
|
249
|
+
result.bySeverity[sev] += stats.empathy.bySeverity[sev];
|
|
250
|
+
result.scoreBySeverity[sev] += stats.empathy.scoreBySeverity[sev];
|
|
251
|
+
}
|
|
252
|
+
result.byDetectionMode.structured += stats.empathy.byDetectionMode.structured;
|
|
253
|
+
result.byDetectionMode.legacy_tag += stats.empathy.byDetectionMode.legacy_tag;
|
|
254
|
+
for (const org of ['assistant_self_report', 'user_manual', 'system_infer']) {
|
|
255
|
+
result.byOrigin[org] += stats.empathy.byOrigin[org];
|
|
256
|
+
}
|
|
257
|
+
result.confidenceDistribution.high += stats.empathy.confidenceDistribution.high;
|
|
258
|
+
result.confidenceDistribution.medium += stats.empathy.confidenceDistribution.medium;
|
|
259
|
+
result.confidenceDistribution.low += stats.empathy.confidenceDistribution.low;
|
|
260
|
+
if (stats.empathy.totalEvents > 0 || stats.empathy.dedupedCount > 0) {
|
|
261
|
+
result.dailyTrend.push({
|
|
262
|
+
date: dateStr,
|
|
263
|
+
count: stats.empathy.totalEvents,
|
|
264
|
+
score: stats.empathy.totalPenaltyScore,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// Today only
|
|
271
|
+
const stats = this.getDailyStats(today);
|
|
272
|
+
Object.assign(result, stats.empathy);
|
|
273
|
+
if (stats.empathy.totalEvents > 0 || stats.empathy.dedupedCount > 0) {
|
|
274
|
+
result.dailyTrend = [{
|
|
275
|
+
date: today,
|
|
276
|
+
count: stats.empathy.totalEvents,
|
|
277
|
+
score: stats.empathy.totalPenaltyScore,
|
|
278
|
+
}];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Calculate dedupe hit rate
|
|
282
|
+
const total = result.totalEvents + result.dedupedCount;
|
|
283
|
+
result.dedupeHitRate = total > 0 ? result.dedupedCount / total : 0;
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Aggregate empathy stats for a specific session.
|
|
288
|
+
*/
|
|
289
|
+
aggregateSessionEmpathy(sessionId, result) {
|
|
290
|
+
// Check event buffer first
|
|
291
|
+
for (const entry of this.eventBuffer) {
|
|
292
|
+
if (entry.sessionId === sessionId && entry.type === 'pain_signal') {
|
|
293
|
+
const data = entry.data;
|
|
294
|
+
if (data.source === 'user_empathy') {
|
|
295
|
+
if (data.deduped) {
|
|
296
|
+
result.dedupedCount++;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
result.totalEvents++;
|
|
300
|
+
result.totalPenaltyScore += data.score || 0;
|
|
301
|
+
if (data.severity) {
|
|
302
|
+
result.bySeverity[data.severity]++;
|
|
303
|
+
result.scoreBySeverity[data.severity] += data.score || 0;
|
|
304
|
+
}
|
|
305
|
+
if (data.detection_mode)
|
|
306
|
+
result.byDetectionMode[data.detection_mode]++;
|
|
307
|
+
if (data.origin)
|
|
308
|
+
result.byOrigin[data.origin]++;
|
|
309
|
+
const conf = data.confidence ?? 1;
|
|
310
|
+
if (conf >= 0.8)
|
|
311
|
+
result.confidenceDistribution.high++;
|
|
312
|
+
else if (conf >= 0.5)
|
|
313
|
+
result.confidenceDistribution.medium++;
|
|
314
|
+
else
|
|
315
|
+
result.confidenceDistribution.low++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else if (entry.sessionId === sessionId && entry.type === 'empathy_rollback') {
|
|
320
|
+
const data = entry.data;
|
|
321
|
+
result.rollbackCount++;
|
|
322
|
+
result.rolledBackScore += data.originalScore || 0;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Also check events file for persisted events
|
|
326
|
+
if (fs.existsSync(this.eventsFile)) {
|
|
327
|
+
try {
|
|
328
|
+
const content = fs.readFileSync(this.eventsFile, 'utf-8');
|
|
329
|
+
const lines = content.trim().split('\n');
|
|
330
|
+
for (const line of lines) {
|
|
331
|
+
if (!line.trim())
|
|
332
|
+
continue;
|
|
333
|
+
try {
|
|
334
|
+
const entry = JSON.parse(line);
|
|
335
|
+
if (entry.sessionId === sessionId) {
|
|
336
|
+
if (entry.type === 'pain_signal') {
|
|
337
|
+
const data = entry.data;
|
|
338
|
+
if (data.source === 'user_empathy') {
|
|
339
|
+
if (data.deduped) {
|
|
340
|
+
result.dedupedCount++;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
result.totalEvents++;
|
|
344
|
+
result.totalPenaltyScore += data.score || 0;
|
|
345
|
+
if (data.severity) {
|
|
346
|
+
result.bySeverity[data.severity]++;
|
|
347
|
+
result.scoreBySeverity[data.severity] += data.score || 0;
|
|
348
|
+
}
|
|
349
|
+
if (data.detection_mode)
|
|
350
|
+
result.byDetectionMode[data.detection_mode]++;
|
|
351
|
+
if (data.origin)
|
|
352
|
+
result.byOrigin[data.origin]++;
|
|
353
|
+
const conf = data.confidence ?? 1;
|
|
354
|
+
if (conf >= 0.8)
|
|
355
|
+
result.confidenceDistribution.high++;
|
|
356
|
+
else if (conf >= 0.5)
|
|
357
|
+
result.confidenceDistribution.medium++;
|
|
358
|
+
else
|
|
359
|
+
result.confidenceDistribution.low++;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else if (entry.type === 'empathy_rollback') {
|
|
364
|
+
const data = entry.data;
|
|
365
|
+
result.rollbackCount++;
|
|
366
|
+
result.rolledBackScore += data.originalScore || 0;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Skip malformed lines
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
if (this.logger)
|
|
377
|
+
this.logger.error(`[PD] Failed to read events.jsonl: ${String(e)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Rollback an empathy event by ID.
|
|
383
|
+
* Returns the rolled back score, or 0 if event not found.
|
|
384
|
+
*/
|
|
385
|
+
rollbackEmpathyEvent(eventId, sessionId, reason, triggeredBy) {
|
|
386
|
+
// Search for the event in buffer and file
|
|
387
|
+
let foundEvent = null;
|
|
388
|
+
// Check buffer first
|
|
389
|
+
for (const entry of this.eventBuffer) {
|
|
390
|
+
if (entry.type === 'pain_signal') {
|
|
391
|
+
const data = entry.data;
|
|
392
|
+
if (entry.data.eventId === eventId && data.source === 'user_empathy') {
|
|
393
|
+
foundEvent = { entry, data };
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// If not in buffer, check file
|
|
399
|
+
if (!foundEvent && fs.existsSync(this.eventsFile)) {
|
|
400
|
+
try {
|
|
401
|
+
const content = fs.readFileSync(this.eventsFile, 'utf-8');
|
|
402
|
+
const lines = content.trim().split('\n');
|
|
403
|
+
for (const line of lines) {
|
|
404
|
+
if (!line.trim())
|
|
405
|
+
continue;
|
|
406
|
+
try {
|
|
407
|
+
const entry = JSON.parse(line);
|
|
408
|
+
if (entry.type === 'pain_signal') {
|
|
409
|
+
const data = entry.data;
|
|
410
|
+
if (entry.data.eventId === eventId && data.source === 'user_empathy') {
|
|
411
|
+
foundEvent = { entry, data };
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// Skip malformed lines
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
if (this.logger)
|
|
423
|
+
this.logger.error(`[PD] Failed to read events.jsonl: ${String(e)}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (!foundEvent || foundEvent.data.deduped) {
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
const originalScore = foundEvent.data.score || 0;
|
|
430
|
+
// Record the rollback event
|
|
431
|
+
this.recordEmpathyRollback(sessionId, {
|
|
432
|
+
eventId,
|
|
433
|
+
originalScore,
|
|
434
|
+
originalSessionId: foundEvent.entry.sessionId,
|
|
435
|
+
reason,
|
|
436
|
+
triggeredBy,
|
|
437
|
+
});
|
|
438
|
+
return originalScore;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Get the last empathy event ID for a session (for rollback).
|
|
442
|
+
*/
|
|
443
|
+
getLastEmpathyEventId(sessionId) {
|
|
444
|
+
// Check buffer in reverse
|
|
445
|
+
for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
|
|
446
|
+
const entry = this.eventBuffer[i];
|
|
447
|
+
if (entry.sessionId === sessionId && entry.type === 'pain_signal') {
|
|
448
|
+
const data = entry.data;
|
|
449
|
+
if (data.source === 'user_empathy' && !data.deduped) {
|
|
450
|
+
return entry.data.eventId || null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Check file
|
|
455
|
+
if (fs.existsSync(this.eventsFile)) {
|
|
456
|
+
try {
|
|
457
|
+
const content = fs.readFileSync(this.eventsFile, 'utf-8');
|
|
458
|
+
const lines = content.trim().split('\n');
|
|
459
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
460
|
+
if (!lines[i].trim())
|
|
461
|
+
continue;
|
|
462
|
+
try {
|
|
463
|
+
const entry = JSON.parse(lines[i]);
|
|
464
|
+
if (entry.sessionId === sessionId && entry.type === 'pain_signal') {
|
|
465
|
+
const data = entry.data;
|
|
466
|
+
if (data.source === 'user_empathy' && !data.deduped) {
|
|
467
|
+
return entry.data.eventId || null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
// Skip malformed lines
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch (e) {
|
|
477
|
+
if (this.logger)
|
|
478
|
+
this.logger.error(`[PD] Failed to read events.jsonl: ${String(e)}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
167
483
|
/**
|
|
168
484
|
* Dispose of the EventLog, flushing pending data and clearing timer.
|
|
169
485
|
*/
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CURRENT_FOCUS 历史版本管理
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - 压缩时备份当前版本到历史目录
|
|
6
|
+
* - 清理过期历史版本
|
|
7
|
+
* - 读取历史版本(用于 full 模式)
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 获取历史目录路径
|
|
11
|
+
*/
|
|
12
|
+
export declare function getHistoryDir(focusPath: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* 从 CURRENT_FOCUS.md 提取版本号
|
|
15
|
+
* 支持整数和小数版本(如 v1, v1.1, v1.2)
|
|
16
|
+
*/
|
|
17
|
+
export declare function extractVersion(content: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* 从 CURRENT_FOCUS.md 提取更新日期
|
|
20
|
+
*/
|
|
21
|
+
export declare function extractDate(content: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* 备份当前版本到历史目录
|
|
24
|
+
*
|
|
25
|
+
* @param focusPath CURRENT_FOCUS.md 的完整路径
|
|
26
|
+
* @param content 当前内容
|
|
27
|
+
* @returns 备份文件路径,失败返回 null
|
|
28
|
+
*/
|
|
29
|
+
export declare function backupToHistory(focusPath: string, content: string): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* 清理过期历史版本
|
|
32
|
+
*
|
|
33
|
+
* @param focusPath CURRENT_FOCUS.md 的完整路径
|
|
34
|
+
* @param maxFiles 最大保留数量
|
|
35
|
+
*/
|
|
36
|
+
export declare function cleanupHistory(focusPath: string, maxFiles?: number): void;
|
|
37
|
+
/**
|
|
38
|
+
* 获取历史版本列表
|
|
39
|
+
*
|
|
40
|
+
* @param focusPath CURRENT_FOCUS.md 的完整路径
|
|
41
|
+
* @param count 获取数量
|
|
42
|
+
* @returns 历史版本内容数组(按时间倒序)
|
|
43
|
+
*/
|
|
44
|
+
export declare function getHistoryVersions(focusPath: string, count?: number): string[];
|
|
45
|
+
/**
|
|
46
|
+
* 压缩 CURRENT_FOCUS.md
|
|
47
|
+
*
|
|
48
|
+
* @param focusPath CURRENT_FOCUS.md 的完整路径
|
|
49
|
+
* @param newContent 新内容
|
|
50
|
+
* @returns 压缩后的信息
|
|
51
|
+
*/
|
|
52
|
+
export declare function compressFocus(focusPath: string, newContent: string): {
|
|
53
|
+
backupPath: string | null;
|
|
54
|
+
cleanedCount: number;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* 智能摘要提取
|
|
58
|
+
*
|
|
59
|
+
* 优先提取关键章节,确保不丢失重要信息
|
|
60
|
+
* 对于非结构化内容,回退到简单的行截取
|
|
61
|
+
*
|
|
62
|
+
* @param content CURRENT_FOCUS.md 内容
|
|
63
|
+
* @param maxLines 最大行数
|
|
64
|
+
*/
|
|
65
|
+
export declare function extractSummary(content: string, maxLines?: number): string;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CURRENT_FOCUS 历史版本管理
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - 压缩时备份当前版本到历史目录
|
|
6
|
+
* - 清理过期历史版本
|
|
7
|
+
* - 读取历史版本(用于 full 模式)
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
/**
|
|
12
|
+
* 简单的日志记录器
|
|
13
|
+
*/
|
|
14
|
+
function logError(message, error) {
|
|
15
|
+
const timestamp = new Date().toISOString();
|
|
16
|
+
const errorStr = error instanceof Error ? error.message : String(error);
|
|
17
|
+
console.error(`[focus-history] ${timestamp} ERROR: ${message}${errorStr ? ' - ' + errorStr : ''}`);
|
|
18
|
+
}
|
|
19
|
+
/** 历史版本保留数量 */
|
|
20
|
+
const MAX_HISTORY_FILES = 10;
|
|
21
|
+
/** full 模式读取的历史版本数 */
|
|
22
|
+
const FULL_MODE_HISTORY_COUNT = 3;
|
|
23
|
+
/**
|
|
24
|
+
* 获取历史目录路径
|
|
25
|
+
*/
|
|
26
|
+
export function getHistoryDir(focusPath) {
|
|
27
|
+
return path.join(path.dirname(focusPath), '.history');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 从 CURRENT_FOCUS.md 提取版本号
|
|
31
|
+
* 支持整数和小数版本(如 v1, v1.1, v1.2)
|
|
32
|
+
*/
|
|
33
|
+
export function extractVersion(content) {
|
|
34
|
+
const match = content.match(/\*\*版本\*\*:\s*v([\d.]+)/i);
|
|
35
|
+
return match ? match[1] : '1';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 从 CURRENT_FOCUS.md 提取更新日期
|
|
39
|
+
*/
|
|
40
|
+
export function extractDate(content) {
|
|
41
|
+
const match = content.match(/\*\*更新\*\*:\s*(\d{4}-\d{2}-\d{2})/);
|
|
42
|
+
return match ? match[1] : new Date().toISOString().split('T')[0];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 备份当前版本到历史目录
|
|
46
|
+
*
|
|
47
|
+
* @param focusPath CURRENT_FOCUS.md 的完整路径
|
|
48
|
+
* @param content 当前内容
|
|
49
|
+
* @returns 备份文件路径,失败返回 null
|
|
50
|
+
*/
|
|
51
|
+
export function backupToHistory(focusPath, content) {
|
|
52
|
+
try {
|
|
53
|
+
const historyDir = getHistoryDir(focusPath);
|
|
54
|
+
// 确保历史目录存在
|
|
55
|
+
if (!fs.existsSync(historyDir)) {
|
|
56
|
+
try {
|
|
57
|
+
fs.mkdirSync(historyDir, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
logError(`Failed to create history directory: ${historyDir}`, error);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const version = extractVersion(content);
|
|
65
|
+
const date = extractDate(content);
|
|
66
|
+
// 使用时间戳作为唯一标识,避免同名冲突
|
|
67
|
+
const timestamp = Date.now();
|
|
68
|
+
const backupName = `CURRENT_FOCUS.v${version}.${date}.${timestamp}.md`;
|
|
69
|
+
const backupPath = path.join(historyDir, backupName);
|
|
70
|
+
// 如果备份已存在,跳过
|
|
71
|
+
if (fs.existsSync(backupPath)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
fs.writeFileSync(backupPath, content, 'utf-8');
|
|
76
|
+
return backupPath;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
logError(`Failed to write backup file: ${backupPath}`, error);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
logError('Unexpected error in backupToHistory', error);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 清理过期历史版本
|
|
90
|
+
*
|
|
91
|
+
* @param focusPath CURRENT_FOCUS.md 的完整路径
|
|
92
|
+
* @param maxFiles 最大保留数量
|
|
93
|
+
*/
|
|
94
|
+
export function cleanupHistory(focusPath, maxFiles = MAX_HISTORY_FILES) {
|
|
95
|
+
try {
|
|
96
|
+
const historyDir = getHistoryDir(focusPath);
|
|
97
|
+
if (!fs.existsSync(historyDir)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// 获取所有历史文件并按修改时间排序(最新的在前)
|
|
101
|
+
const files = fs.readdirSync(historyDir)
|
|
102
|
+
.filter(f => f.startsWith('CURRENT_FOCUS.v') && f.endsWith('.md'))
|
|
103
|
+
.map(f => ({
|
|
104
|
+
name: f,
|
|
105
|
+
path: path.join(historyDir, f),
|
|
106
|
+
mtime: fs.statSync(path.join(historyDir, f)).mtime.getTime()
|
|
107
|
+
}))
|
|
108
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
109
|
+
// 删除超出数量的文件
|
|
110
|
+
const toDelete = files.slice(maxFiles);
|
|
111
|
+
for (const file of toDelete) {
|
|
112
|
+
try {
|
|
113
|
+
fs.unlinkSync(file.path);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
// 单个文件删除失败不应中断整个清理过程
|
|
117
|
+
logError(`Failed to delete history file: ${file.path}`, error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
logError('Unexpected error in cleanupHistory', error);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 获取历史版本列表
|
|
127
|
+
*
|
|
128
|
+
* @param focusPath CURRENT_FOCUS.md 的完整路径
|
|
129
|
+
* @param count 获取数量
|
|
130
|
+
* @returns 历史版本内容数组(按时间倒序)
|
|
131
|
+
*/
|
|
132
|
+
export function getHistoryVersions(focusPath, count = FULL_MODE_HISTORY_COUNT) {
|
|
133
|
+
const historyDir = getHistoryDir(focusPath);
|
|
134
|
+
if (!fs.existsSync(historyDir)) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
// 获取所有历史文件并按修改时间排序(最新的在前)
|
|
138
|
+
const files = fs.readdirSync(historyDir)
|
|
139
|
+
.filter(f => f.startsWith('CURRENT_FOCUS.v') && f.endsWith('.md'))
|
|
140
|
+
.map(f => ({
|
|
141
|
+
path: path.join(historyDir, f),
|
|
142
|
+
mtime: fs.statSync(path.join(historyDir, f)).mtime.getTime()
|
|
143
|
+
}))
|
|
144
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
145
|
+
.slice(0, count);
|
|
146
|
+
return files.map(f => fs.readFileSync(f.path, 'utf-8'));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 压缩 CURRENT_FOCUS.md
|
|
150
|
+
*
|
|
151
|
+
* @param focusPath CURRENT_FOCUS.md 的完整路径
|
|
152
|
+
* @param newContent 新内容
|
|
153
|
+
* @returns 压缩后的信息
|
|
154
|
+
*/
|
|
155
|
+
export function compressFocus(focusPath, newContent) {
|
|
156
|
+
// 读取当前内容
|
|
157
|
+
let oldContent = '';
|
|
158
|
+
if (fs.existsSync(focusPath)) {
|
|
159
|
+
oldContent = fs.readFileSync(focusPath, 'utf-8');
|
|
160
|
+
}
|
|
161
|
+
// 备份当前版本
|
|
162
|
+
const backupPath = oldContent ? backupToHistory(focusPath, oldContent) : null;
|
|
163
|
+
// 递增版本号(支持小数版本)
|
|
164
|
+
const oldVersion = extractVersion(oldContent);
|
|
165
|
+
// 解析版本号并递增
|
|
166
|
+
const versionParts = oldVersion.split('.');
|
|
167
|
+
const majorVersion = parseInt(versionParts[0], 10) || 1;
|
|
168
|
+
const newVersion = `${majorVersion + 1}`;
|
|
169
|
+
const today = new Date().toISOString().split('T')[0];
|
|
170
|
+
// 更新版本号和日期
|
|
171
|
+
const updatedContent = newContent
|
|
172
|
+
.replace(/\*\*版本\*\*:\s*v[\d.]+/i, `**版本**: v${newVersion}`)
|
|
173
|
+
.replace(/\*\*更新\*\*:\s*\d{4}-\d{2}-\d{2}/, `**更新**: ${today}`);
|
|
174
|
+
// 写入新内容
|
|
175
|
+
fs.writeFileSync(focusPath, updatedContent, 'utf-8');
|
|
176
|
+
// 清理过期历史
|
|
177
|
+
const historyDir = getHistoryDir(focusPath);
|
|
178
|
+
const beforeCount = fs.existsSync(historyDir)
|
|
179
|
+
? fs.readdirSync(historyDir).filter(f => f.startsWith('CURRENT_FOCUS.v')).length
|
|
180
|
+
: 0;
|
|
181
|
+
cleanupHistory(focusPath);
|
|
182
|
+
const afterCount = fs.existsSync(historyDir)
|
|
183
|
+
? fs.readdirSync(historyDir).filter(f => f.startsWith('CURRENT_FOCUS.v')).length
|
|
184
|
+
: 0;
|
|
185
|
+
return {
|
|
186
|
+
backupPath,
|
|
187
|
+
cleanedCount: beforeCount - afterCount
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* 智能摘要提取
|
|
192
|
+
*
|
|
193
|
+
* 优先提取关键章节,确保不丢失重要信息
|
|
194
|
+
* 对于非结构化内容,回退到简单的行截取
|
|
195
|
+
*
|
|
196
|
+
* @param content CURRENT_FOCUS.md 内容
|
|
197
|
+
* @param maxLines 最大行数
|
|
198
|
+
*/
|
|
199
|
+
export function extractSummary(content, maxLines = 30) {
|
|
200
|
+
const lines = content.split('\n');
|
|
201
|
+
const sections = {
|
|
202
|
+
header: [], // 标题和元数据
|
|
203
|
+
snapshot: [], // 状态快照
|
|
204
|
+
current: [], // 当前任务
|
|
205
|
+
nextSteps: [], // 下一步
|
|
206
|
+
reference: [] // 参考
|
|
207
|
+
};
|
|
208
|
+
let currentSection = 'header';
|
|
209
|
+
let hasStructuredSections = false;
|
|
210
|
+
for (const line of lines) {
|
|
211
|
+
// 识别章节(使用更宽松的匹配,支持不同格式)
|
|
212
|
+
const trimmedLine = line.trim();
|
|
213
|
+
// 使用正则匹配,支持 h1-h3 和多种格式
|
|
214
|
+
if (/^#{1,3}\s*.*状态快照|📍/.test(trimmedLine)) {
|
|
215
|
+
currentSection = 'snapshot';
|
|
216
|
+
hasStructuredSections = true;
|
|
217
|
+
}
|
|
218
|
+
else if (/^#{1,3}\s*.*当前任务|🔄/.test(trimmedLine)) {
|
|
219
|
+
currentSection = 'current';
|
|
220
|
+
hasStructuredSections = true;
|
|
221
|
+
}
|
|
222
|
+
else if (/^#{1,3}\s*.*下一步|➡️/.test(trimmedLine)) {
|
|
223
|
+
currentSection = 'nextSteps';
|
|
224
|
+
hasStructuredSections = true;
|
|
225
|
+
}
|
|
226
|
+
else if (/^#{1,3}\s*.*参考|📎/.test(trimmedLine)) {
|
|
227
|
+
currentSection = 'reference';
|
|
228
|
+
hasStructuredSections = true;
|
|
229
|
+
}
|
|
230
|
+
else if (trimmedLine === '---') {
|
|
231
|
+
continue; // 跳过分隔线
|
|
232
|
+
}
|
|
233
|
+
else if (line.startsWith('<!--')) {
|
|
234
|
+
continue; // 跳过注释
|
|
235
|
+
}
|
|
236
|
+
sections[currentSection].push(line);
|
|
237
|
+
}
|
|
238
|
+
// 如果没有结构化章节,回退到简单的行截取
|
|
239
|
+
if (!hasStructuredSections) {
|
|
240
|
+
const result = lines.slice(0, maxLines);
|
|
241
|
+
if (lines.length > maxLines) {
|
|
242
|
+
result.push('');
|
|
243
|
+
result.push('...[truncated, see CURRENT_FOCUS.md for full context]');
|
|
244
|
+
}
|
|
245
|
+
return result.join('\n');
|
|
246
|
+
}
|
|
247
|
+
// 按优先级拼接
|
|
248
|
+
const result = [
|
|
249
|
+
...sections.header.slice(0, 5), // 标题 + 元数据
|
|
250
|
+
'',
|
|
251
|
+
'---',
|
|
252
|
+
'',
|
|
253
|
+
...sections.snapshot.slice(0, 10), // 状态快照
|
|
254
|
+
'',
|
|
255
|
+
...sections.nextSteps.slice(0, 10), // 下一步(优先级高)
|
|
256
|
+
'',
|
|
257
|
+
...sections.current.slice(0, 15), // 当前任务
|
|
258
|
+
];
|
|
259
|
+
// 限制总行数
|
|
260
|
+
const trimmed = result.slice(0, maxLines);
|
|
261
|
+
if (result.length > maxLines) {
|
|
262
|
+
trimmed.push('');
|
|
263
|
+
trimmed.push('...[truncated, see CURRENT_FOCUS.md for full context]');
|
|
264
|
+
}
|
|
265
|
+
return trimmed.join('\n');
|
|
266
|
+
}
|