principles-disciple 1.37.0 → 1.39.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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/capabilities.ts +2 -1
- package/src/commands/context.ts +5 -4
- package/src/commands/focus.ts +5 -4
- package/src/commands/nocturnal-train.ts +13 -12
- package/src/commands/pd-reflect.ts +4 -3
- package/src/commands/promote-impl.ts +2 -1
- package/src/commands/rollback-impl.ts +6 -5
- package/src/core/adaptive-thresholds.ts +3 -4
- package/src/core/code-implementation-storage.ts +6 -5
- package/src/core/config.ts +3 -2
- package/src/core/correction-cue-learner.ts +42 -14
- package/src/core/correction-types.ts +1 -1
- package/src/core/diagnostician-task-store.ts +5 -8
- package/src/core/dictionary.ts +3 -2
- package/src/core/empathy-keyword-matcher.ts +5 -4
- package/src/core/event-log.ts +2 -1
- package/src/core/evolution-engine.ts +3 -3
- package/src/core/evolution-reducer.ts +4 -3
- package/src/core/focus-history.ts +20 -17
- package/src/core/hygiene/tracker.ts +2 -1
- package/src/core/init.ts +2 -1
- package/src/core/model-deployment-registry.ts +3 -4
- package/src/core/model-training-registry.ts +3 -4
- package/src/core/nocturnal-artifact-lineage.ts +2 -3
- package/src/core/nocturnal-dataset.ts +4 -5
- package/src/core/nocturnal-export.ts +8 -7
- package/src/core/pain.ts +6 -6
- package/src/core/path-resolver.ts +2 -1
- package/src/core/pd-task-reconciler.ts +4 -5
- package/src/core/pd-task-store.ts +3 -4
- package/src/core/principle-tree-ledger.ts +6 -5
- package/src/core/promotion-gate.ts +8 -9
- package/src/core/replay-engine.ts +10 -10
- package/src/core/session-tracker.ts +2 -1
- package/src/core/shadow-observation-registry.ts +3 -4
- package/src/core/training-program.ts +2 -1
- package/src/core/trajectory.ts +8 -7
- package/src/hooks/gate-block-helper.ts +3 -3
- package/src/hooks/lifecycle.ts +3 -2
- package/src/hooks/llm.ts +3 -2
- package/src/hooks/prompt.ts +77 -18
- package/src/service/cooldown-strategy.ts +97 -0
- package/src/service/evolution-worker.ts +133 -272
- package/src/service/failure-classifier.ts +79 -0
- package/src/service/keyword-optimization-service.ts +22 -15
- package/src/service/nocturnal-config.ts +149 -7
- package/src/service/nocturnal-runtime.ts +24 -4
- package/src/service/nocturnal-service.ts +1 -8
- package/src/service/startup-reconciler.ts +112 -0
- package/src/service/subagent-workflow/correction-observer-types.ts +2 -2
- package/src/service/subagent-workflow/correction-observer-workflow-manager.ts +1 -1
- package/src/service/subagent-workflow/index.ts +0 -14
- package/src/service/subagent-workflow/workflow-manager-base.ts +5 -5
- package/src/tools/write-pain-flag.ts +2 -3
- package/src/utils/io.ts +43 -1
- package/tests/core/dictionary.test.ts +4 -1
- package/tests/service/cooldown-strategy.test.ts +163 -0
- package/tests/service/failure-classifier.test.ts +171 -0
- package/tests/service/startup-reconciler.test.ts +148 -0
- package/src/service/correction-observer-types.ts +0 -69
- package/src/service/correction-observer-workflow-manager.ts +0 -247
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
|
|
5
5
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
6
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
6
7
|
|
|
7
8
|
const TOOLS_TO_SCAN = [
|
|
8
9
|
{ name: 'rg', cmd: ['rg', '--version'] },
|
|
@@ -43,7 +44,7 @@ function scanEnvironment(wctx: WorkspaceContext): any {
|
|
|
43
44
|
if (!fs.existsSync(capsDir)) {
|
|
44
45
|
fs.mkdirSync(capsDir, { recursive: true });
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
atomicWriteFileSync(capsPath, JSON.stringify(capabilities, null, 2));
|
|
47
48
|
|
|
48
49
|
return capabilities;
|
|
49
50
|
}
|
package/src/commands/context.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
|
|
4
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
4
5
|
import type { ContextInjectionConfig} from '../types.js';
|
|
5
6
|
import { defaultContextConfig } from '../types.js';
|
|
6
7
|
import { loadContextInjectionConfig } from '../hooks/prompt.js';
|
|
@@ -40,7 +41,7 @@ function saveConfig(workspaceDir: string, config: ContextInjectionConfig): boole
|
|
|
40
41
|
profile.contextInjection = config;
|
|
41
42
|
|
|
42
43
|
// Write back
|
|
43
|
-
|
|
44
|
+
atomicWriteFileSync(profilePath, JSON.stringify(profile, null, 2));
|
|
44
45
|
return true;
|
|
45
46
|
} catch (e) {
|
|
46
47
|
console.error(`[PD:Context] Failed to save config: ${String(e)}`);
|
|
@@ -99,7 +100,7 @@ function showStatus(workspaceDir: string, isZh: boolean): string {
|
|
|
99
100
|
* Toggle a boolean setting
|
|
100
101
|
*/
|
|
101
102
|
|
|
102
|
-
|
|
103
|
+
|
|
103
104
|
function toggleSetting(
|
|
104
105
|
workspaceDir: string,
|
|
105
106
|
key: 'thinkingOs' | 'reflectionLog',
|
|
@@ -215,7 +216,7 @@ function applyPreset(
|
|
|
215
216
|
isZh: boolean
|
|
216
217
|
): string {
|
|
217
218
|
|
|
218
|
-
|
|
219
|
+
|
|
219
220
|
let config: ContextInjectionConfig;
|
|
220
221
|
|
|
221
222
|
switch (preset) {
|
|
@@ -318,7 +319,7 @@ export function handleContextCommand(ctx: PluginCommandContext): PluginCommandRe
|
|
|
318
319
|
const isZh = (ctx.config?.language as string) === 'zh';
|
|
319
320
|
|
|
320
321
|
|
|
321
|
-
|
|
322
|
+
|
|
322
323
|
let result: string;
|
|
323
324
|
|
|
324
325
|
switch (subCommand) {
|
package/src/commands/focus.ts
CHANGED
|
@@ -12,6 +12,7 @@ import * as fs from 'fs';
|
|
|
12
12
|
import * as path from 'path';
|
|
13
13
|
import type { PluginCommandContext, PluginCommandResult, OpenClawPluginApi } from '../openclaw-sdk.js';
|
|
14
14
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
15
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
15
16
|
import {
|
|
16
17
|
getHistoryDir,
|
|
17
18
|
backupToHistory,
|
|
@@ -283,7 +284,7 @@ async function compressFocus(
|
|
|
283
284
|
|
|
284
285
|
// 5. 压缩内容
|
|
285
286
|
|
|
286
|
-
|
|
287
|
+
|
|
287
288
|
let compressedContent: string;
|
|
288
289
|
try {
|
|
289
290
|
compressedContent = compressFocusContent(oldContent, workspaceDir);
|
|
@@ -304,7 +305,7 @@ async function compressFocus(
|
|
|
304
305
|
const newLines = newContent.split('\n').length;
|
|
305
306
|
const savedLines = oldLines - newLines;
|
|
306
307
|
|
|
307
|
-
|
|
308
|
+
atomicWriteFileSync(focusPath, newContent);
|
|
308
309
|
|
|
309
310
|
const milestoneNote = milestonesArchived
|
|
310
311
|
? isZh
|
|
@@ -410,7 +411,7 @@ function rollbackFocus(workspaceDir: string, index: number, isZh: boolean): stri
|
|
|
410
411
|
`**状态**: ROLLBACK (from v${restoredVersion})`
|
|
411
412
|
);
|
|
412
413
|
|
|
413
|
-
|
|
414
|
+
atomicWriteFileSync(focusPath, restoredContent);
|
|
414
415
|
|
|
415
416
|
if (isZh) {
|
|
416
417
|
return `✅ **回滚成功**
|
|
@@ -481,7 +482,7 @@ export async function handleFocusCommand(
|
|
|
481
482
|
const isZh = (ctx.config?.language as string) === 'zh';
|
|
482
483
|
|
|
483
484
|
|
|
484
|
-
|
|
485
|
+
|
|
485
486
|
let result: string;
|
|
486
487
|
|
|
487
488
|
switch (subCommand) {
|
|
@@ -26,6 +26,7 @@ import * as path from 'path';
|
|
|
26
26
|
import * as fs from 'fs';
|
|
27
27
|
import { execFileSync, spawn } from 'child_process';
|
|
28
28
|
import { fileURLToPath } from 'url';
|
|
29
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
29
30
|
import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
|
|
30
31
|
import {
|
|
31
32
|
type TrainerBackendKind,
|
|
@@ -264,8 +265,8 @@ Hardware tiers:
|
|
|
264
265
|
if (!fs.existsSync(workspaceCheckpointsDir)) {
|
|
265
266
|
fs.mkdirSync(workspaceCheckpointsDir, { recursive: true });
|
|
266
267
|
}
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
atomicWriteFileSync(trainerSpecPath, JSON.stringify(spec, null, 2));
|
|
269
|
+
atomicWriteFileSync(workspaceSpecPath, JSON.stringify(spec, null, 2));
|
|
269
270
|
|
|
270
271
|
// --- Auto-run mode: execute trainer immediately ---
|
|
271
272
|
// This closes the gap in the create-experiment -> trainer -> import-result chain.
|
|
@@ -282,10 +283,10 @@ Hardware tiers:
|
|
|
282
283
|
if (!fs.existsSync(specDir)) {
|
|
283
284
|
fs.mkdirSync(specDir, { recursive: true });
|
|
284
285
|
}
|
|
285
|
-
|
|
286
|
+
atomicWriteFileSync(specPath, JSON.stringify(spec, null, 2));
|
|
286
287
|
|
|
287
288
|
|
|
288
|
-
|
|
289
|
+
|
|
289
290
|
let trainerResult!: TrainingExperimentResult;
|
|
290
291
|
|
|
291
292
|
try {
|
|
@@ -395,7 +396,7 @@ Hardware tiers:
|
|
|
395
396
|
// Process trainer result (register checkpoint)
|
|
396
397
|
// dry_run returns null (no checkpoint); other statuses throw on error
|
|
397
398
|
|
|
398
|
-
|
|
399
|
+
|
|
399
400
|
let processed: { checkpointId: string; checkpointRef: string } | null;
|
|
400
401
|
try {
|
|
401
402
|
processed = program.processResult({
|
|
@@ -537,7 +538,7 @@ Next steps:
|
|
|
537
538
|
}
|
|
538
539
|
}
|
|
539
540
|
|
|
540
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
541
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Reason: JSON.parse returns dynamic JSON - type unknown at parse time, narrowed via type narrowing below
|
|
541
542
|
let result: any;
|
|
542
543
|
try {
|
|
543
544
|
result = JSON.parse(resultJson);
|
|
@@ -569,7 +570,7 @@ Next steps:
|
|
|
569
570
|
// Process the result
|
|
570
571
|
const program = new TrainingProgram(workspaceDir);
|
|
571
572
|
|
|
572
|
-
|
|
573
|
+
|
|
573
574
|
let processed: { checkpointId: string; checkpointRef: string } | null;
|
|
574
575
|
try {
|
|
575
576
|
processed = program.processResult({
|
|
@@ -757,15 +758,15 @@ Next steps:
|
|
|
757
758
|
}
|
|
758
759
|
|
|
759
760
|
// Destructure benchmark result - delta property contains the actual delta value
|
|
760
|
-
|
|
761
|
+
|
|
761
762
|
delta = benchmarkResult.delta.delta;
|
|
762
|
-
|
|
763
|
+
|
|
763
764
|
baselineScore = benchmarkResult.delta.baselineScore;
|
|
764
|
-
|
|
765
|
+
|
|
765
766
|
candidateScore = benchmarkResult.delta.candidateScore;
|
|
766
|
-
|
|
767
|
+
|
|
767
768
|
benchmarkId = benchmarkResult.benchmarkId;
|
|
768
|
-
|
|
769
|
+
|
|
769
770
|
verdict = benchmarkResult.verdict;
|
|
770
771
|
} else {
|
|
771
772
|
// Manual mode: require explicit delta and verdict
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { PluginCommandDefinition, PluginCommandContext, PluginCommandResult, OpenClawPluginApi } from '../openclaw-sdk.js';
|
|
9
9
|
import { acquireQueueLock, EVOLUTION_QUEUE_LOCK_SUFFIX } from '../service/evolution-worker.js';
|
|
10
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
10
11
|
import * as fs from 'fs';
|
|
11
12
|
import * as path from 'path';
|
|
12
13
|
|
|
@@ -22,7 +23,7 @@ export const handlePdReflect: PluginCommandDefinition = {
|
|
|
22
23
|
requireAuth: false,
|
|
23
24
|
handler: async (ctx: PdReflectContext): Promise<PluginCommandResult> => {
|
|
24
25
|
try {
|
|
25
|
-
|
|
26
|
+
|
|
26
27
|
const workspaceDir = ctx.workspaceDir;
|
|
27
28
|
if (!workspaceDir) {
|
|
28
29
|
return { text: 'Cannot determine workspace directory. Ensure you are in an active workspace.', isError: true };
|
|
@@ -33,7 +34,7 @@ export const handlePdReflect: PluginCommandDefinition = {
|
|
|
33
34
|
|
|
34
35
|
// Acquire lock before modifying queue
|
|
35
36
|
const releaseLock = await acquireQueueLock(queuePath, ctx.api?.logger, EVOLUTION_QUEUE_LOCK_SUFFIX);
|
|
36
|
-
|
|
37
|
+
|
|
37
38
|
let taskId: string | undefined;
|
|
38
39
|
try {
|
|
39
40
|
let rawQueue: unknown[] = [];
|
|
@@ -71,7 +72,7 @@ export const handlePdReflect: PluginCommandDefinition = {
|
|
|
71
72
|
maxRetries: 1,
|
|
72
73
|
});
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
atomicWriteFileSync(queuePath, JSON.stringify(rawQueue, null, 2));
|
|
75
76
|
} finally {
|
|
76
77
|
releaseLock();
|
|
77
78
|
}
|
|
@@ -30,6 +30,7 @@ import { WorkspaceContext } from '../core/workspace-context.js';
|
|
|
30
30
|
import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
|
|
31
31
|
import type { Implementation } from '../types/principle-tree-schema.js';
|
|
32
32
|
import { withLock } from '../utils/file-lock.js';
|
|
33
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
33
34
|
|
|
34
35
|
function getAllImplementations(stateDir: string): Implementation[] {
|
|
35
36
|
const ledger = loadLedger(stateDir);
|
|
@@ -227,7 +228,7 @@ function _handlePromoteImpl(options: PromoteImplOptions): PluginCommandResult {
|
|
|
227
228
|
promotedAt: new Date().toISOString(),
|
|
228
229
|
};
|
|
229
230
|
withLock(eventPath, () => {
|
|
230
|
-
|
|
231
|
+
atomicWriteFileSync(eventPath, JSON.stringify(promotionEvent, null, 2));
|
|
231
232
|
});
|
|
232
233
|
try {
|
|
233
234
|
refreshPrincipleLifecycle(workspaceDir, stateDir);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import * as fs from 'fs';
|
|
17
17
|
import * as path from 'path';
|
|
18
18
|
import { withLock } from '../utils/file-lock.js';
|
|
19
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
19
20
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
20
21
|
import { refreshPrincipleLifecycle } from '../core/principle-internalization/lifecycle-refresh.js';
|
|
21
22
|
import {
|
|
@@ -59,12 +60,12 @@ export function handleRollbackImplCommand(ctx: PluginCommandContext): PluginComm
|
|
|
59
60
|
// List active
|
|
60
61
|
if (subcommand === 'list' || subcommand === '') {
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
|
|
63
64
|
return _handleListActiveRollback(stateDir, isZh);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
|
|
68
69
|
return _handleRollbackImpl(workspaceDir, stateDir, implId, reason, isZh, ctx.sessionId);
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -107,7 +108,7 @@ function _handleListActiveRollback(
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
|
|
111
112
|
function _handleRollbackImpl(
|
|
112
113
|
workspaceDir: string,
|
|
113
114
|
stateDir: string,
|
|
@@ -143,7 +144,7 @@ function _handleRollbackImpl(
|
|
|
143
144
|
transitionImplementationState(stateDir, implId, 'disabled');
|
|
144
145
|
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
|
|
147
148
|
let restoredMessage: string;
|
|
148
149
|
|
|
149
150
|
if (previousActiveId && allImpls.some((i) => i.id === previousActiveId)) {
|
|
@@ -188,7 +189,7 @@ function _handleRollbackImpl(
|
|
|
188
189
|
const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
189
190
|
const rollbackPath = path.join(rollbackDir, `${rollbackTimestamp}.json`);
|
|
190
191
|
withLock(rollbackPath, () => {
|
|
191
|
-
|
|
192
|
+
atomicWriteFileSync(rollbackPath, JSON.stringify(rollbackRecord, null, 2));
|
|
192
193
|
});
|
|
193
194
|
try {
|
|
194
195
|
refreshPrincipleLifecycle(workspaceDir, stateDir);
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import * as fs from 'fs';
|
|
25
25
|
import * as path from 'path';
|
|
26
26
|
import { withLock } from '../utils/file-lock.js';
|
|
27
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
27
28
|
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
29
30
|
// Constants
|
|
@@ -227,9 +228,7 @@ function writeState(stateDir: string, state: ThresholdPersistenceState): void {
|
|
|
227
228
|
}
|
|
228
229
|
|
|
229
230
|
withLock(statePath, () => {
|
|
230
|
-
|
|
231
|
-
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8');
|
|
232
|
-
fs.renameSync(tmpPath, statePath);
|
|
231
|
+
atomicWriteFileSync(statePath, JSON.stringify(state, null, 2));
|
|
233
232
|
});
|
|
234
233
|
}
|
|
235
234
|
|
|
@@ -301,7 +300,7 @@ export function getEffectiveThresholds(stateDir: string): ThresholdValues {
|
|
|
301
300
|
* @returns UpdateThresholdResult
|
|
302
301
|
*/
|
|
303
302
|
|
|
304
|
-
|
|
303
|
+
|
|
305
304
|
export function updateThresholdState(
|
|
306
305
|
stateDir: string,
|
|
307
306
|
thresholdName: ThresholdName,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import * as fs from 'fs';
|
|
19
19
|
import * as path from 'path';
|
|
20
20
|
import { withLock } from '../utils/file-lock.js';
|
|
21
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
24
|
// Types
|
|
@@ -130,12 +131,12 @@ export function writeManifest(
|
|
|
130
131
|
const manifestPath = path.join(assetRoot, MANIFEST_FILENAME);
|
|
131
132
|
ensureDir(assetRoot);
|
|
132
133
|
withLock(manifestPath, () => {
|
|
133
|
-
|
|
134
|
+
atomicWriteFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
134
135
|
});
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
|
|
138
|
-
|
|
139
|
+
|
|
139
140
|
export function writeEntrySource(
|
|
140
141
|
stateDir: string,
|
|
141
142
|
implId: string,
|
|
@@ -147,7 +148,7 @@ export function writeEntrySource(
|
|
|
147
148
|
const entryPath = path.join(assetRoot, entryFile);
|
|
148
149
|
ensureDir(assetRoot);
|
|
149
150
|
withLock(entryPath, () => {
|
|
150
|
-
|
|
151
|
+
atomicWriteFileSync(entryPath, sourceCode);
|
|
151
152
|
});
|
|
152
153
|
}
|
|
153
154
|
|
|
@@ -191,7 +192,7 @@ export function loadEntrySource(stateDir: string, implId: string): string | null
|
|
|
191
192
|
* Idempotent: calling again with the same implId will NOT overwrite an existing entry.js.
|
|
192
193
|
*/
|
|
193
194
|
|
|
194
|
-
|
|
195
|
+
|
|
195
196
|
export function createImplementationAssetDir(
|
|
196
197
|
stateDir: string,
|
|
197
198
|
implId: string,
|
|
@@ -219,7 +220,7 @@ export function createImplementationAssetDir(
|
|
|
219
220
|
ensureDir(assetRoot);
|
|
220
221
|
ensureDir(replaysDir);
|
|
221
222
|
if (!fs.existsSync(entryPath)) {
|
|
222
|
-
|
|
223
|
+
atomicWriteFileSync(entryPath, entrySource);
|
|
223
224
|
}
|
|
224
225
|
});
|
|
225
226
|
|
package/src/core/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
3
4
|
|
|
4
5
|
export interface DeepReflectionSettings {
|
|
5
6
|
enabled: boolean;
|
|
@@ -257,7 +258,7 @@ export class PainConfig {
|
|
|
257
258
|
if (!fs.existsSync(dir)) {
|
|
258
259
|
fs.mkdirSync(dir, { recursive: true });
|
|
259
260
|
}
|
|
260
|
-
|
|
261
|
+
atomicWriteFileSync(this.filePath, JSON.stringify(this.settings, null, 2));
|
|
261
262
|
console.log(`[PD:Config] Settings saved to ${this.filePath}`);
|
|
262
263
|
} catch (e) {
|
|
263
264
|
console.error(`[PD:Config] Failed to save settings: ${String(e)}`);
|
|
@@ -291,7 +292,7 @@ export class PainConfig {
|
|
|
291
292
|
* Basic validation for critical settings
|
|
292
293
|
*/
|
|
293
294
|
|
|
294
|
-
|
|
295
|
+
|
|
295
296
|
private validate(settings: PainSettings): void {
|
|
296
297
|
// Ensure intervals are positive
|
|
297
298
|
if (settings.intervals.worker_poll_ms < 1000) settings.intervals.worker_poll_ms = 15 * 60 * 1000;
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
MAX_CORRECTION_KEYWORDS,
|
|
23
23
|
} from './correction-types.js';
|
|
24
24
|
import { checkCooldown } from '../service/nocturnal-runtime.js';
|
|
25
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
25
26
|
|
|
26
27
|
const KEYWORD_STORE_FILE = 'correction_keywords.json';
|
|
27
28
|
|
|
@@ -109,11 +110,9 @@ export function saveCorrectionKeywordStore(
|
|
|
109
110
|
store: CorrectionKeywordStore
|
|
110
111
|
): void {
|
|
111
112
|
const filePath = path.join(stateDir, KEYWORD_STORE_FILE);
|
|
112
|
-
const tmpPath = filePath + '.tmp';
|
|
113
113
|
|
|
114
114
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
115
|
-
|
|
116
|
-
fs.renameSync(tmpPath, filePath);
|
|
115
|
+
atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
|
|
117
116
|
|
|
118
117
|
// Invalidate cache so the next read re-loads from disk (D-05)
|
|
119
118
|
_correctionCueCache = null;
|
|
@@ -151,9 +150,12 @@ export class CorrectionCueLearner {
|
|
|
151
150
|
|
|
152
151
|
/**
|
|
153
152
|
* Checks whether text contains a correction cue (D-11).
|
|
153
|
+
* Pure read-only — does NOT modify the store.
|
|
154
154
|
* Normalisation is equivalent to the original detectCorrectionCue():
|
|
155
155
|
* trim → lowercase → strip punctuation
|
|
156
156
|
* Returns weighted score based on keyword accuracy (D-39-03, D-39-04).
|
|
157
|
+
*
|
|
158
|
+
* To record hits/TPs, call recordHit() and recordTruePositive() separately.
|
|
157
159
|
*/
|
|
158
160
|
match(text: string): CorrectionMatchResult {
|
|
159
161
|
const normalized = text
|
|
@@ -167,19 +169,15 @@ export class CorrectionCueLearner {
|
|
|
167
169
|
for (const keyword of this.store.keywords) {
|
|
168
170
|
if (normalized.includes(keyword.term.toLowerCase())) {
|
|
169
171
|
// D-39-03, D-39-04: Weighted score formula
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
+
// No history (tp=0, fp=0) → accuracy = 1 (trust raw weight)
|
|
173
|
+
// Has history → accuracy = tp / (tp + fp) (proportional to true positive rate)
|
|
172
174
|
const tp = keyword.truePositiveCount ?? 0;
|
|
173
175
|
const fp = keyword.falsePositiveCount ?? 0;
|
|
174
|
-
const accuracy = (tp +
|
|
176
|
+
const accuracy = (tp + fp) > 0 ? tp / (tp + fp) : 1;
|
|
175
177
|
const score = keyword.weight * accuracy;
|
|
176
178
|
|
|
177
179
|
totalScore += score;
|
|
178
180
|
matchedTerms.push(keyword.term);
|
|
179
|
-
|
|
180
|
-
// Increment hitCount
|
|
181
|
-
keyword.hitCount = (keyword.hitCount ?? 0) + 1;
|
|
182
|
-
keyword.lastHitAt = new Date().toISOString();
|
|
183
181
|
}
|
|
184
182
|
}
|
|
185
183
|
|
|
@@ -199,17 +197,40 @@ export class CorrectionCueLearner {
|
|
|
199
197
|
};
|
|
200
198
|
}
|
|
201
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Records a keyword hit (for hitCount/FPR tracking).
|
|
202
|
+
* Increments hitCount and updates lastHitAt for all matched terms.
|
|
203
|
+
* Intentionally does NOT flush — hitCount is best-effort analytics,
|
|
204
|
+
* persisted by the next recordTruePositive() or flush() call.
|
|
205
|
+
*/
|
|
206
|
+
recordHits(terms: string[]): void {
|
|
207
|
+
for (const term of terms) {
|
|
208
|
+
const keywordIndex = this.store.keywords.findIndex(k => k.term.toLowerCase() === term.toLowerCase());
|
|
209
|
+
if (keywordIndex < 0) continue;
|
|
210
|
+
const keyword = this.store.keywords[keywordIndex];
|
|
211
|
+
this.store.keywords[keywordIndex] = {
|
|
212
|
+
...keyword,
|
|
213
|
+
hitCount: (keyword.hitCount ?? 0) + 1,
|
|
214
|
+
lastHitAt: new Date().toISOString(),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
202
219
|
/**
|
|
203
220
|
* Records a confirmed true positive for the given keyword term.
|
|
204
|
-
* Increments
|
|
221
|
+
* Increments truePositiveCount atomically.
|
|
205
222
|
*/
|
|
206
223
|
recordTruePositive(term: string): void {
|
|
207
224
|
const keyword = this.store.keywords.find(k => k.term.toLowerCase() === term.toLowerCase());
|
|
208
225
|
if (!keyword) return;
|
|
209
226
|
|
|
210
227
|
keyword.truePositiveCount = (keyword.truePositiveCount ?? 0) + 1;
|
|
211
|
-
|
|
212
|
-
|
|
228
|
+
|
|
229
|
+
// Update in-store reference
|
|
230
|
+
const keywordIndex = this.store.keywords.findIndex(k => k.term.toLowerCase() === term.toLowerCase());
|
|
231
|
+
if (keywordIndex >= 0) {
|
|
232
|
+
this.store.keywords[keywordIndex] = { ...keyword };
|
|
233
|
+
}
|
|
213
234
|
|
|
214
235
|
this.flush();
|
|
215
236
|
}
|
|
@@ -217,18 +238,25 @@ export class CorrectionCueLearner {
|
|
|
217
238
|
/**
|
|
218
239
|
* Records a confirmed false positive for the given keyword term.
|
|
219
240
|
* CORR-10: Decreases keyword weight by 20% (x0.8 multiplicative factor).
|
|
241
|
+
* D-39-17: Keywords at very low weight (<0.1) still match but contribute minimally.
|
|
220
242
|
*/
|
|
221
243
|
recordFalsePositive(term: string): void {
|
|
222
244
|
const keyword = this.store.keywords.find(k => k.term.toLowerCase() === term.toLowerCase());
|
|
223
245
|
if (!keyword) return;
|
|
224
246
|
|
|
225
247
|
keyword.falsePositiveCount = (keyword.falsePositiveCount ?? 0) + 1;
|
|
226
|
-
keyword.hitCount = (keyword.hitCount ?? 0) + 1;
|
|
227
248
|
|
|
228
249
|
// D-39-15: Multiplicative weight decay x0.8 on confirmed FP
|
|
229
250
|
keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, keyword.weight * 0.8);
|
|
230
251
|
keyword.lastHitAt = new Date().toISOString();
|
|
231
252
|
|
|
253
|
+
// Update in-store reference
|
|
254
|
+
const keywordIndex = this.store.keywords.findIndex(k => k.term.toLowerCase() === term.toLowerCase());
|
|
255
|
+
if (keywordIndex >= 0) {
|
|
256
|
+
this.store.keywords[keywordIndex] = { ...keyword };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// D-39-16: Apply decay BEFORE flush to disk
|
|
232
260
|
this.flush();
|
|
233
261
|
}
|
|
234
262
|
|
|
@@ -45,7 +45,7 @@ export interface CorrectionKeywordStore {
|
|
|
45
45
|
export interface CorrectionMatchResult {
|
|
46
46
|
/** Whether any keyword matched */
|
|
47
47
|
matched: boolean;
|
|
48
|
-
/**
|
|
48
|
+
/** Matched terms (empty array when no match; may be truncated to first N items) */
|
|
49
49
|
matchedTerms: string[];
|
|
50
50
|
/** Weighted score (0-1) based on keyword weight and accuracy */
|
|
51
51
|
score: number;
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import * as fs from 'fs';
|
|
30
30
|
import * as path from 'path';
|
|
31
31
|
import { withLockAsync } from '../utils/file-lock.js';
|
|
32
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
32
33
|
|
|
33
34
|
const DIAGNOSTICIAN_TASKS_FILE = 'diagnostician_tasks.json';
|
|
34
35
|
|
|
@@ -83,16 +84,14 @@ export async function addDiagnosticianTask(
|
|
|
83
84
|
const filePath = resolveTasksPath(stateDir);
|
|
84
85
|
await withLockAsync(filePath, async () => {
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
|
|
87
88
|
const store = readTaskStoreSync(filePath);
|
|
88
89
|
store.tasks[taskId] = {
|
|
89
90
|
prompt,
|
|
90
91
|
createdAt: new Date().toISOString(),
|
|
91
92
|
status: 'pending',
|
|
92
93
|
};
|
|
93
|
-
|
|
94
|
-
fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), 'utf8');
|
|
95
|
-
fs.renameSync(tmpPath, filePath);
|
|
94
|
+
atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
|
|
96
95
|
});
|
|
97
96
|
}
|
|
98
97
|
|
|
@@ -106,13 +105,11 @@ export async function completeDiagnosticianTask(
|
|
|
106
105
|
): Promise<void> {
|
|
107
106
|
const filePath = resolveTasksPath(stateDir);
|
|
108
107
|
await withLockAsync(filePath, async () => {
|
|
108
|
+
|
|
109
109
|
|
|
110
|
-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
111
110
|
const store = readTaskStoreSync(filePath);
|
|
112
111
|
delete store.tasks[taskId];
|
|
113
|
-
|
|
114
|
-
fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), 'utf8');
|
|
115
|
-
fs.renameSync(tmpPath, filePath);
|
|
112
|
+
atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
|
|
116
113
|
});
|
|
117
114
|
}
|
|
118
115
|
|
package/src/core/dictionary.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
3
4
|
|
|
4
5
|
export type RuleType = 'regex' | 'exact_match';
|
|
5
6
|
|
|
@@ -116,7 +117,7 @@ export class PainDictionary {
|
|
|
116
117
|
}
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
|
|
120
|
+
|
|
120
121
|
match(text: string): { ruleId: string; severity: number } | undefined {
|
|
121
122
|
if (shouldIgnorePainProtocolText(text)) return undefined;
|
|
122
123
|
|
|
@@ -154,7 +155,7 @@ export class PainDictionary {
|
|
|
154
155
|
if (!fs.existsSync(this.stateDir)) {
|
|
155
156
|
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
156
157
|
}
|
|
157
|
-
|
|
158
|
+
atomicWriteFileSync(this.filePath, JSON.stringify(this.data, null, 2));
|
|
158
159
|
} catch (e) {
|
|
159
160
|
console.error('[PD] Failed to flush pain_dictionary.json:', e);
|
|
160
161
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import * as fs from 'fs';
|
|
16
16
|
import * as path from 'path';
|
|
17
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
17
18
|
import type {
|
|
18
19
|
EmpathyKeywordStore,
|
|
19
20
|
EmpathyKeywordEntry,
|
|
@@ -81,7 +82,7 @@ export function loadKeywordStore(stateDir: string, language?: 'zh' | 'en'): Empa
|
|
|
81
82
|
if (!fs.existsSync(filePath)) {
|
|
82
83
|
const store = createDefaultKeywordStore(language);
|
|
83
84
|
|
|
84
|
-
|
|
85
|
+
|
|
85
86
|
saveKeywordStore(stateDir, store);
|
|
86
87
|
return store;
|
|
87
88
|
}
|
|
@@ -94,7 +95,7 @@ export function loadKeywordStore(stateDir: string, language?: 'zh' | 'en'): Empa
|
|
|
94
95
|
console.warn('[PD:Empathy] Invalid keyword store format, creating default');
|
|
95
96
|
const store = createDefaultKeywordStore(language);
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
|
|
98
99
|
saveKeywordStore(stateDir, store);
|
|
99
100
|
return store;
|
|
100
101
|
}
|
|
@@ -104,7 +105,7 @@ export function loadKeywordStore(stateDir: string, language?: 'zh' | 'en'): Empa
|
|
|
104
105
|
console.warn(`[PD:Empathy] Failed to load keyword store: ${e}`);
|
|
105
106
|
const store = createDefaultKeywordStore(language);
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
|
|
108
109
|
saveKeywordStore(stateDir, store);
|
|
109
110
|
return store;
|
|
110
111
|
}
|
|
@@ -123,7 +124,7 @@ export function saveKeywordStore(stateDir: string, store: EmpathyKeywordStore):
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
store.lastUpdated = new Date().toISOString();
|
|
126
|
-
|
|
127
|
+
atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
// =========================================================================
|