principles-disciple 1.35.0 → 1.36.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.
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.35.0",
5
+ "version": "1.36.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -21,10 +21,17 @@ import {
21
21
  CORRECTION_SEED_KEYWORDS,
22
22
  MAX_CORRECTION_KEYWORDS,
23
23
  } from './correction-types.js';
24
- import { checkCooldown, recordCooldown } from '../service/nocturnal-runtime.js';
24
+ import { checkCooldown } from '../service/nocturnal-runtime.js';
25
25
 
26
26
  const KEYWORD_STORE_FILE = 'correction_keywords.json';
27
27
 
28
+ // CORR-08: Daily optimization throttle (uses checkCooldown in nocturnal-runtime.ts)
29
+ // Note: throttle state is stored in nocturnal-runtime.json, not a separate file.
30
+
31
+ // Weight bounds for correction keywords (D-39-03, D-39-15)
32
+ const MIN_KEYWORD_WEIGHT = 0.1;
33
+ const MAX_KEYWORD_WEIGHT = 0.9;
34
+
28
35
  // =========================================================================
29
36
  // Module-level cache (D-04, D-05)
30
37
  // =========================================================================
@@ -112,6 +119,8 @@ export function saveCorrectionKeywordStore(
112
119
  _correctionCueCache = null;
113
120
  }
114
121
 
122
+ // =========================================================================
123
+ // Throttle helpers (CORR-08)
115
124
  // =========================================================================
116
125
  // Singleton state
117
126
  // =========================================================================
@@ -217,7 +226,7 @@ export class CorrectionCueLearner {
217
226
  keyword.hitCount = (keyword.hitCount ?? 0) + 1;
218
227
 
219
228
  // D-39-15: Multiplicative weight decay x0.8 on confirmed FP
220
- keyword.weight = Math.max(0.1, keyword.weight * 0.8);
229
+ keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, keyword.weight * 0.8);
221
230
  keyword.lastHitAt = new Date().toISOString();
222
231
 
223
232
  this.flush();
@@ -238,10 +247,10 @@ export class CorrectionCueLearner {
238
247
 
239
248
  /**
240
249
  * Records that an optimization was performed.
241
- * Increments the daily throttle counter and updates lastOptimizedAt.
250
+ * Updates lastOptimizedAt for the store. Throttle state is managed
251
+ * by checkCooldown() — no separate throttle file needed (CORR-08).
242
252
  */
243
- async recordOptimizationPerformed(): Promise<void> {
244
- await recordCooldown(this.stateDir, 24 * 60 * 60 * 1000);
253
+ recordOptimizationPerformed(): void {
245
254
  this.store.lastOptimizedAt = new Date().toISOString();
246
255
  this.flush();
247
256
  }
@@ -270,14 +279,20 @@ export class CorrectionCueLearner {
270
279
  * Throws if keyword not found.
271
280
  */
272
281
  updateWeight(term: string, weight: number): void {
273
- const idx = this.store.keywords.findIndex(
282
+ const keyword = this.store.keywords.find(
274
283
  k => k.term.toLowerCase() === term.toLowerCase()
275
284
  );
276
- if (idx < 0) {
285
+ if (!keyword) {
277
286
  throw new Error(`Keyword not found: ${term}`);
278
287
  }
279
288
 
280
- this.store.keywords[idx].weight = Math.max(0.1, Math.min(0.9, weight));
289
+ keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, Math.min(MAX_KEYWORD_WEIGHT, weight)); // Clamp to MIN-MAX_KEYWORD_WEIGHT
290
+ const idx = this.store.keywords.findIndex(
291
+ k => k.term.toLowerCase() === term.toLowerCase()
292
+ );
293
+ if (idx >= 0) {
294
+ this.store.keywords[idx] = { ...keyword };
295
+ }
281
296
  this.flush();
282
297
  }
283
298
 
package/src/core/init.ts CHANGED
@@ -46,7 +46,7 @@ export function ensureWorkspaceTemplates(api: OpenClawPluginApi, workspaceDir: s
46
46
  if (fs.existsSync(commonTemplatesDir)) {
47
47
  api.logger.info(`[PD] Syncing workspace templates: ${workspaceDir}...`);
48
48
 
49
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
49
+
50
50
  copyRecursiveSync(commonTemplatesDir, workspaceDir, api);
51
51
  }
52
52
 
@@ -89,7 +89,7 @@ export function ensureWorkspaceTemplates(api: OpenClawPluginApi, workspaceDir: s
89
89
  fs.mkdirSync(painDestDir, { recursive: true });
90
90
  }
91
91
 
92
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
92
+
93
93
  copyRecursiveSync(painTemplatesDir, painDestDir, api);
94
94
  }
95
95
 
@@ -368,7 +368,7 @@ export async function handleBeforePromptBuild(
368
368
  // prependContext: Only short dynamic directives: evolutionDirective + heartbeat
369
369
 
370
370
 
371
- // eslint-disable-next-line @typescript-eslint/init-declarations
371
+
372
372
  let prependSystemContext: string;
373
373
  let prependContext = '';
374
374
  let appendSystemContext = '';
@@ -684,7 +684,7 @@ ${taskBlocks}${processingNote}
684
684
 
685
685
  // ──── 6. Dynamic Attitude Matrix (based on GFI) ────
686
686
 
687
- // eslint-disable-next-line @typescript-eslint/init-declarations
687
+
688
688
  let attitudeDirective: string;
689
689
  const currentGfi = session?.currentGfi || 0;
690
690
 
@@ -910,7 +910,7 @@ ${taskBlocks}${processingNote}
910
910
  const toolMatches = toolPatterns.flatMap(({ pattern, tool }) => {
911
911
  const matches: string[] = [];
912
912
 
913
- // eslint-disable-next-line @typescript-eslint/init-declarations
913
+
914
914
  let _m;
915
915
  const r = new RegExp(pattern.source, pattern.flags);
916
916
 
@@ -16,11 +16,10 @@ import { getEvolutionLogger } from '../core/evolution-logger.js';
16
16
  import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
17
17
  export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
18
18
  import { LockUnavailableError } from '../config/index.js';
19
- import { PAIN_QUEUE_DEDUP_WINDOW_MS } from '../config/defaults/runtime.js';
20
19
  import { checkWorkspaceIdle, checkCooldown } from './nocturnal-runtime.js';
21
20
  import { loadNocturnalConfig } from './nocturnal-config.js';
22
21
  import { WorkflowStore } from './subagent-workflow/workflow-store.js';
23
- import type { WorkflowRow, RecentPainContext } from './subagent-workflow/types.js';
22
+ import type { WorkflowRow } from './subagent-workflow/types.js';
24
23
  import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
25
24
  import { DeepReflectWorkflowManager } from './subagent-workflow/deep-reflect-workflow-manager.js';
26
25
  import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
@@ -32,22 +31,14 @@ import {
32
31
  import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-contract.js';
33
32
  import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
34
33
  import { readPainFlagContract } from '../core/pain.js';
35
- import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './correction-observer-workflow-manager.js';
36
- import type { CorrectionObserverPayload } from './correction-observer-types.js';
34
+ import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './subagent-workflow/correction-observer-workflow-manager.js';
35
+ import type { CorrectionObserverPayload } from './subagent-workflow/correction-observer-types.js';
37
36
  import { KeywordOptimizationService } from './keyword-optimization-service.js';
38
37
  import { TrajectoryRegistry } from '../core/trajectory.js';
39
38
  import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
40
- import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
41
- import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
42
39
 
43
- /**
44
- * Atomic file write — write to temp then rename to prevent partial writes on crash.
45
- */
46
- function atomicWriteFileSync(filePath: string, data: string): void {
47
- const tmpPath = filePath + '.tmp';
48
- fs.writeFileSync(tmpPath, data, 'utf8');
49
- fs.renameSync(tmpPath, filePath);
50
- }
40
+ const WORKFLOW_TTL_MS = 5 * 60 * 1000; // 5 minutes default TTL for helper workflows
41
+ import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
51
42
 
52
43
  // ── Workflow Watchdog ────────────────────────────────────────────────────────
53
44
  // Detects stale/orphaned workflows, invalid results, and cleanup failures.
@@ -209,6 +200,27 @@ let timeoutId: NodeJS.Timeout | null = null;
209
200
  export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
210
201
  export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation';
211
202
 
203
+ /**
204
+ * Recent pain context attached to sleep_reflection tasks.
205
+ * Carries explicit recent pain signal metadata without being a separate task kind.
206
+ * Used by NocturnalTargetSelector for ranking bias and context enrichment.
207
+ */
208
+ export interface RecentPainContext {
209
+ /** Most recent unresolved pain event */
210
+ mostRecent: {
211
+ score: number;
212
+ source: string;
213
+ reason: string;
214
+ timestamp: string;
215
+ /** Session ID where the pain occurred */
216
+ sessionId: string;
217
+ } | null;
218
+ /** Count of pain events in the recent window (for signal strength) */
219
+ recentPainCount: number;
220
+ /** Highest pain score in the recent window */
221
+ recentMaxPainScore: number;
222
+ }
223
+
212
224
  export interface EvolutionQueueItem {
213
225
  // Core identity
214
226
  id: string;
@@ -414,6 +426,7 @@ function buildFallbackNocturnalSnapshot(
414
426
  };
415
427
  }
416
428
 
429
+ const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
417
430
 
418
431
  // P0 fix: File lock constants and helper for queue operations (prevents TOCTOU race)
419
432
  export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
@@ -711,7 +724,7 @@ function enqueueNewSleepReflectionTask(
711
724
  recentPainContext,
712
725
  });
713
726
 
714
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
727
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
715
728
  logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
716
729
  }
717
730
 
@@ -856,7 +869,7 @@ async function doEnqueuePainTask(
856
869
  retryCount: 0, maxRetries: 3,
857
870
  });
858
871
 
859
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
872
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
860
873
  fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
861
874
  result.enqueued = true;
862
875
 
@@ -1645,7 +1658,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1645
1658
 
1646
1659
  // Write claimed state (includes any pain changes from above) and release lock
1647
1660
  if (queueChanged) {
1648
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1661
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
1649
1662
  }
1650
1663
  releaseLock();
1651
1664
  for (const sleepTask of sleepReflectionTasks) {
@@ -1899,7 +1912,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1899
1912
  freshQueue[idx] = sleepTask;
1900
1913
  }
1901
1914
  }
1902
- atomicWriteFileSync(queuePath, JSON.stringify(freshQueue, null, 2));
1915
+ fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2), 'utf8');
1903
1916
 
1904
1917
  // Log completions to EvolutionLogger
1905
1918
  for (const sleepTask of sleepReflectionTasks) {
@@ -1992,14 +2005,10 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1992
2005
  };
1993
2006
 
1994
2007
  // Dispatch LLM subagent via CorrectionObserverWorkflowManager
1995
- const subagent = api?.runtime?.subagent;
1996
- if (!subagent) {
1997
- throw new Error('[PD:EvolutionWorker] subagent runtime not available for keyword_optimization');
1998
- }
1999
2008
  const manager = new CorrectionObserverWorkflowManager({
2000
2009
  workspaceDir: wctx.workspaceDir,
2001
2010
  logger,
2002
- subagent,
2011
+ subagent: api?.runtime?.subagent!,
2003
2012
  agentSession: api?.runtime?.agent?.session,
2004
2013
  });
2005
2014
 
@@ -2013,11 +2022,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
2013
2022
  workflowId = handle.workflowId;
2014
2023
  koTask.resultRef = workflowId;
2015
2024
  } else {
2016
- // isPolling implies resultRef exists (checked above)
2017
- workflowId = koTask.resultRef;
2018
- if (!workflowId) {
2019
- throw new Error(`[PD:EvolutionWorker] keyword_optimization task ${koTask.id} has no resultRef in polling mode`);
2020
- }
2025
+ workflowId = koTask.resultRef!;
2021
2026
  }
2022
2027
 
2023
2028
  // Poll workflow state
@@ -2029,7 +2034,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
2029
2034
 
2030
2035
  if (parsedResult?.updated) {
2031
2036
  koService.applyResult(parsedResult);
2032
- await learner.recordOptimizationPerformed();
2037
+ learner.recordOptimizationPerformed();
2033
2038
  logger?.info?.(`[PD:EvolutionWorker] keyword_optimization applied mutations: ${parsedResult.summary}`);
2034
2039
  } else {
2035
2040
  logger?.info?.(`[PD:EvolutionWorker] keyword_optimization completed with no updates`);
@@ -2082,7 +2087,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
2082
2087
  freshQueue.push(koTask);
2083
2088
  }
2084
2089
  }
2085
- fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2));
2090
+ fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2), 'utf8');
2086
2091
  } catch (koResultErr) {
2087
2092
  logger?.warn?.(`[PD:EvolutionWorker] Failed to write keyword_optimization results: ${String(koResultErr)}`);
2088
2093
  } finally {
@@ -2092,7 +2097,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
2092
2097
  }
2093
2098
 
2094
2099
  if (queueChanged) {
2095
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
2100
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
2096
2101
  }
2097
2102
 
2098
2103
  // Pipeline observability: log stage-level summary at end of cycle
@@ -2210,7 +2215,7 @@ export async function registerEvolutionTaskSession(
2210
2215
  if (!task.started_at) {
2211
2216
  task.started_at = new Date().toISOString();
2212
2217
  }
2213
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
2218
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
2214
2219
  return true;
2215
2220
  } finally {
2216
2221
  releaseLock();
@@ -2250,7 +2255,7 @@ interface WorkerStatusReport {
2250
2255
  function writeWorkerStatus(stateDir: string, report: WorkerStatusReport): void {
2251
2256
  try {
2252
2257
  const statusPath = path.join(stateDir, 'worker-status.json');
2253
- atomicWriteFileSync(statusPath, JSON.stringify(report, null, 2));
2258
+ fs.writeFileSync(statusPath, JSON.stringify(report, null, 2), 'utf8');
2254
2259
  } catch (statusErr) {
2255
2260
  // Non-critical: worker-status.json is for monitoring, failure is acceptable
2256
2261
  // (no logger available in this standalone helper)
@@ -2281,7 +2286,7 @@ async function processEvolutionQueueWithResult(
2281
2286
  const purgeResult = purgeStaleFailedTasks(queue, logger);
2282
2287
  if (purgeResult.purged > 0) {
2283
2288
  // Write back the cleaned queue
2284
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
2289
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
2285
2290
  }
2286
2291
 
2287
2292
  queueResult.total = queue.length;
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
10
- import type { CorrectionObserverResult } from './correction-observer-types.js';
10
+ import type { CorrectionObserverResult } from './subagent-workflow/correction-observer-types.js';
11
11
  import type { PluginLogger } from '../openclaw-sdk.js';
12
12
  import { TrajectoryRegistry } from '../core/trajectory.js';
13
13
 
@@ -137,4 +137,4 @@ export type TrajectoryHistoryEntry = {
137
137
  };
138
138
 
139
139
  /** Re-export CorrectionObserverPayload for convenience */
140
- export type { CorrectionObserverPayload } from './correction-observer-types.js';
140
+ export type { CorrectionObserverPayload } from './subagent-workflow/correction-observer-types.js';
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Correction Observer Workflow - Type Definitions
3
+ *
4
+ * Types for the correction observer LLM optimization workflow.
5
+ * This workflow dispatches an LLM subagent to analyze keyword performance
6
+ * and recommend ADD/UPDATE/REMOVE actions for the correction keyword store.
7
+ */
8
+
9
+ import type { SubagentWorkflowSpec } from './types.js';
10
+
11
+ /**
12
+ * Input passed to the correction observer subagent.
13
+ */
14
+ export interface CorrectionObserverPayload {
15
+ /** Parent session that triggered the optimization */
16
+ parentSessionId: string;
17
+ /** Workspace directory */
18
+ workspaceDir: string;
19
+ /** Current keyword store summary for context */
20
+ keywordStoreSummary: {
21
+ totalKeywords: number;
22
+ terms: Array<{
23
+ term: string;
24
+ weight: number;
25
+ hitCount: number;
26
+ truePositiveCount: number;
27
+ falsePositiveCount: number;
28
+ }>;
29
+ };
30
+ /** Recent user messages for pattern analysis */
31
+ recentMessages: string[];
32
+
33
+ /**
34
+ * Trajectory history: user turns where correctionDetected=true (D-40-08).
35
+ * Includes term matched, timestamp, sessionId for FPR trend analysis.
36
+ */
37
+ trajectoryHistory: Array<{
38
+ sessionId: string;
39
+ timestamp: string;
40
+ term: string;
41
+ userMessage: string;
42
+ }>;
43
+ }
44
+
45
+ /**
46
+ * Result from the correction observer subagent.
47
+ */
48
+ export interface CorrectionObserverResult {
49
+ /** Whether any changes were made */
50
+ updated: boolean;
51
+ /** The optimization decisions returned by the LLM */
52
+ updates: Record<string, {
53
+ action: 'add' | 'update' | 'remove';
54
+ weight?: number;
55
+ falsePositiveRate?: number;
56
+ reasoning: string;
57
+ }>;
58
+ /** Human-readable summary */
59
+ summary: string;
60
+ }
61
+
62
+ /**
63
+ * Workflow spec for the correction observer optimization workflow.
64
+ */
65
+ export interface CorrectionObserverWorkflowSpec extends SubagentWorkflowSpec<CorrectionObserverResult> {
66
+ workflowType: 'correction_observer';
67
+ payload: CorrectionObserverPayload;
68
+ result?: CorrectionObserverResult;
69
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * CorrectionObserverWorkflowManager
3
+ *
4
+ * Workflow manager that dispatches an LLM subagent to optimize correction
5
+ * keywords based on recent match performance data and user feedback.
6
+ *
7
+ * Follows the established WorkflowManagerBase pattern from EmpathyObserverWorkflowManager.
8
+ */
9
+
10
+ import type { PluginLogger } from '../../openclaw-sdk.js';
11
+ import type {
12
+ SubagentWorkflowSpec,
13
+ WorkflowMetadata,
14
+ WorkflowResultContext,
15
+ WorkflowPersistContext,
16
+ WorkflowHandle,
17
+ } from './types.js';
18
+ import type { RuntimeDirectDriver } from './runtime-direct-driver.js';
19
+ import { WorkflowManagerBase } from './workflow-manager-base.js';
20
+ import { isSubagentRuntimeAvailable } from '../../utils/subagent-probe.js';
21
+ import type {
22
+ CorrectionObserverPayload,
23
+ CorrectionObserverResult,
24
+ } from './correction-observer-types.js';
25
+
26
+ const WORKFLOW_SESSION_PREFIX = 'agent:main:subagent:workflow-correction-';
27
+
28
+ const DEFAULT_TIMEOUT_MS = 30_000;
29
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
30
+
31
+ // Prompt formatting constants
32
+ const MAX_TRAJECTORY_MESSAGE_LENGTH = 80;
33
+
34
+ // ── Options ─────────────────────────────────────────────────────────────────
35
+
36
+ export interface CorrectionObserverWorkflowOptions {
37
+ workspaceDir: string;
38
+ logger: PluginLogger;
39
+ subagent: RuntimeDirectDriver['subagent'];
40
+ /** Pass api.runtime.agent.session to enable heartbeat-safe cleanup (#188) */
41
+ agentSession?: RuntimeDirectDriver['agentSession'];
42
+ }
43
+
44
+ // ── Helper Functions ─────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Extract raw assistant text from messages or assistantTexts array.
48
+ */
49
+ function extractAssistantTextForSpec(messages: unknown[], assistantTexts?: string[]): string {
50
+ if (assistantTexts && assistantTexts.length > 0) {
51
+ return assistantTexts[assistantTexts.length - 1] || '';
52
+ }
53
+ for (let i = messages.length - 1; i >= 0; i--) {
54
+ const msg = messages[i] as { role?: string; content?: unknown };
55
+ if (msg?.role !== 'assistant') continue;
56
+ if (typeof msg.content === 'string') return msg.content;
57
+ if (Array.isArray(msg.content)) {
58
+ const txt = msg.content
59
+ .filter((part: unknown) => part && typeof part === 'object' && (part as { type?: string }).type === 'text' && typeof (part as { text?: unknown }).text === 'string')
60
+ .map((part: unknown) => (part as { text: string }).text)
61
+ .join('\n');
62
+ if (txt) return txt;
63
+ }
64
+ }
65
+ return '';
66
+ }
67
+
68
+ /**
69
+ * Parse correction observer JSON payload from raw text.
70
+ */
71
+ function parseCorrectionObserverPayload(rawText: string): CorrectionObserverResult | null {
72
+ if (!rawText?.trim()) return null;
73
+ try {
74
+ return JSON.parse(rawText.trim()) as CorrectionObserverResult;
75
+ } catch {
76
+ const match = /\{[\s\S]*\}/.exec(rawText);
77
+ if (!match) return null;
78
+ try {
79
+ return JSON.parse(match[0]) as CorrectionObserverResult;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+ }
85
+
86
+ // ── Workflow Spec ─────────────────────────────────────────────────────────────
87
+
88
+ export const correctionObserverWorkflowSpec: SubagentWorkflowSpec<CorrectionObserverResult> = {
89
+ workflowType: 'correction_observer',
90
+ transport: 'runtime_direct',
91
+ timeoutMs: 30_000,
92
+ ttlMs: 300_000,
93
+ shouldDeleteSessionAfterFinalize: true,
94
+
95
+ buildPrompt(taskInput: unknown, _metadata: WorkflowMetadata): string {
96
+ const payload = taskInput as CorrectionObserverPayload;
97
+ const { keywordStoreSummary, recentMessages, trajectoryHistory } = payload;
98
+
99
+ const termsList = keywordStoreSummary.terms
100
+ .map(t => ` - term="${t.term}", weight=${t.weight}, hits=${t.hitCount}, TP=${t.truePositiveCount}, FP=${t.falsePositiveCount}`)
101
+ .join('\n');
102
+
103
+ const messages = recentMessages.length > 0
104
+ ? recentMessages.map(m => ` - ${JSON.stringify(m)}`).join('\n')
105
+ : ' (none)';
106
+
107
+ const trajectory = trajectoryHistory.length > 0
108
+ ? trajectoryHistory.map(t => ` - [${t.sessionId}] ${t.term} (${t.timestamp}): ${t.userMessage.substring(0, MAX_TRAJECTORY_MESSAGE_LENGTH)}`)
109
+ .join('\n')
110
+ : ' (none)';
111
+
112
+ return [
113
+ 'You are a correction keyword optimizer.',
114
+ '',
115
+ '## TASK',
116
+ 'Analyze the current correction keyword store and recent user messages.',
117
+ 'Recommend ADD/UPDATE/REMOVE actions to improve correction cue accuracy.',
118
+ '',
119
+ '## Current Keyword Store (' + keywordStoreSummary.totalKeywords + ' terms):',
120
+ termsList,
121
+ '',
122
+ '## Recent User Messages (' + recentMessages.length + ' messages):',
123
+ messages,
124
+ '',
125
+ '## Correction Trajectory (recent confirmed corrections, D-40-08):',
126
+ trajectory,
127
+ '',
128
+ '## Rules:',
129
+ '- ADD: If a correction pattern is detected in messages but not in store',
130
+ '- UPDATE: If a term\'s weight should change based on TP/FP ratio',
131
+ '- REMOVE: If a term has 0 hits after many uses AND high false positive rate (>0.3)',
132
+ '- Keep reasoning concise (max 100 chars)',
133
+ '- Weight range: 0.1-0.9',
134
+ '',
135
+ 'Return strict JSON (no markdown):',
136
+ '{"updated": boolean, "updates": {...}, "summary": string}',
137
+ ].join('\n');
138
+ },
139
+
140
+ async parseResult(ctx: WorkflowResultContext): Promise<CorrectionObserverResult | null> {
141
+ const rawText = extractAssistantTextForSpec(ctx.messages, ctx.assistantTexts);
142
+ return parseCorrectionObserverPayload(rawText);
143
+ },
144
+
145
+ async persistResult(_ctx: WorkflowPersistContext<CorrectionObserverResult>): Promise<void> {
146
+ // Result persistence is handled by the caller (evolution-worker.ts)
147
+ // which reads the result and applies keyword store updates.
148
+ // This spec handles only the LLM dispatch and result parsing.
149
+ },
150
+
151
+ shouldFinalizeOnWaitStatus(status: 'ok' | 'error' | 'timeout'): boolean {
152
+ return status === 'ok';
153
+ },
154
+ };
155
+
156
+ // ── Manager Class ─────────────────────────────────────────────────────────────
157
+
158
+ export class CorrectionObserverWorkflowManager extends WorkflowManagerBase {
159
+ constructor(opts: CorrectionObserverWorkflowOptions) {
160
+ super({
161
+ workspaceDir: opts.workspaceDir,
162
+ logger: opts.logger,
163
+ subagent: opts.subagent,
164
+ agentSession: opts.agentSession,
165
+ workflowType: 'correction_observer',
166
+ sessionPrefix: WORKFLOW_SESSION_PREFIX,
167
+ defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
168
+ defaultTtlMs: DEFAULT_TTL_MS,
169
+ });
170
+ }
171
+
172
+ async startWorkflow<TResult>(
173
+ spec: SubagentWorkflowSpec<TResult>,
174
+ options: {
175
+ parentSessionId: string;
176
+ workspaceDir?: string;
177
+ taskInput: unknown;
178
+ metadata?: Record<string, unknown>;
179
+ }
180
+ ): Promise<WorkflowHandle> {
181
+ // Surface degrade: skip boot sessions
182
+ if (options.parentSessionId.startsWith('boot-')) {
183
+ this.logger.info(`[PD:CorrectionObserver] Skipping workflow: boot session`);
184
+ throw new Error(`CorrectionObserverWorkflowManager: cannot start workflow for boot session`);
185
+ }
186
+
187
+ // Surface degrade: check subagent runtime availability
188
+ if (!isSubagentRuntimeAvailable(this.driver.getSubagent())) {
189
+ this.logger.info(`[PD:CorrectionObserver] Skipping workflow: subagent runtime unavailable`);
190
+ throw new Error(`CorrectionObserverWorkflowManager: subagent runtime unavailable`);
191
+ }
192
+
193
+ if (spec.transport !== 'runtime_direct') {
194
+ throw new Error(`CorrectionObserverWorkflowManager only supports runtime_direct transport`);
195
+ }
196
+
197
+ return super.startWorkflow(spec, options);
198
+ }
199
+
200
+ /**
201
+ * Get the parsed workflow result for a completed workflow.
202
+ * Used by callers (evolution-worker.ts) to retrieve LLM optimization results
203
+ * after the workflow completes, so mutations can be applied to the keyword store.
204
+ */
205
+ async getWorkflowResult(workflowId: string): Promise<CorrectionObserverResult | null> {
206
+ const workflow = this.store.getWorkflow(workflowId);
207
+ if (!workflow) return null;
208
+
209
+ const result = await this.driver.getResult({ sessionKey: workflow.child_session_key, limit: 20 });
210
+ return correctionObserverWorkflowSpec.parseResult({
211
+ messages: result.messages,
212
+ assistantTexts: result.assistantTexts,
213
+ metadata: JSON.parse(workflow.metadata_json) as WorkflowMetadata,
214
+ waitStatus: 'ok',
215
+ });
216
+ }
217
+
218
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
219
+ protected override createWorkflowMetadata<TResult>(
220
+ spec: SubagentWorkflowSpec<TResult>,
221
+ options: {
222
+ parentSessionId: string;
223
+ workspaceDir?: string;
224
+ taskInput: unknown;
225
+ metadata?: Record<string, unknown>;
226
+ },
227
+ now: number
228
+ ): WorkflowMetadata {
229
+ return {
230
+ parentSessionId: options.parentSessionId,
231
+ workspaceDir: options.workspaceDir,
232
+ taskInput: options.taskInput,
233
+ startedAt: now,
234
+ workflowType: spec.workflowType,
235
+ ...options.metadata,
236
+ };
237
+ }
238
+ }
239
+
240
+ // ── Factory ─────────────────────────────────────────────────────────────────
241
+
242
+ export function createCorrectionObserverWorkflowManager(
243
+ opts: CorrectionObserverWorkflowOptions
244
+ ): CorrectionObserverWorkflowManager {
245
+ return new CorrectionObserverWorkflowManager(opts);
246
+ }
@@ -65,3 +65,16 @@ export type {
65
65
  WorkflowEventRow,
66
66
  WorkflowDebugSummary,
67
67
  } from './types.js';
68
+
69
+ export {
70
+ CorrectionObserverWorkflowManager,
71
+ createCorrectionObserverWorkflowManager,
72
+ correctionObserverWorkflowSpec,
73
+ type CorrectionObserverWorkflowOptions,
74
+ } from './correction-observer-workflow-manager.js';
75
+
76
+ export type {
77
+ CorrectionObserverPayload,
78
+ CorrectionObserverResult,
79
+ CorrectionObserverWorkflowSpec,
80
+ } from './correction-observer-types.js';
@@ -0,0 +1,345 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import {
5
+ CorrectionCueLearner,
6
+ loadCorrectionKeywordStore,
7
+ saveCorrectionKeywordStore,
8
+ _resetCorrectionCueCache,
9
+ _resetCorrectionCueLearnerInstance,
10
+ } from '../../src/core/correction-cue-learner.js';
11
+ import {
12
+ CORRECTION_SEED_KEYWORDS,
13
+ MAX_CORRECTION_KEYWORDS,
14
+ } from '../../src/core/correction-types.js';
15
+
16
+ // ── Mock fs (hoisted — vi.mock runs before imports) ──────────────────────────
17
+
18
+ vi.mock('fs', () => ({
19
+ existsSync: vi.fn(() => false),
20
+ readFileSync: vi.fn(() => ''),
21
+ writeFileSync: vi.fn(),
22
+ renameSync: vi.fn(),
23
+ mkdirSync: vi.fn(),
24
+ }));
25
+
26
+ import * as fs from 'fs';
27
+
28
+ // ── Helpers ──────────────────────────────────────────────────────────────────
29
+
30
+ function tempDir(): string {
31
+ return path.join(os.tmpdir(), `correction-cue-test-${Date.now()}-${Math.random()}`);
32
+ }
33
+
34
+ // ── Test setup: reset module-level cache and singleton between tests ─────────
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ _resetCorrectionCueCache();
39
+ _resetCorrectionCueLearnerInstance();
40
+ });
41
+
42
+ // ═══════════════════════════════════════════════════════════════════════════════
43
+ // CORR-01: Seed keywords
44
+ // ═══════════════════════════════════════════════════════════════════════════════
45
+
46
+ describe('CORR-01: Seed keywords', () => {
47
+ it('should create store with 16 seed keywords on first load', () => {
48
+ vi.mocked(fs.existsSync).mockReturnValue(false);
49
+ const dir = tempDir();
50
+ const store = loadCorrectionKeywordStore(dir);
51
+ expect(store.keywords).toHaveLength(16);
52
+ expect(store.version).toBe(1);
53
+ });
54
+
55
+ it('should set source=seed and non-empty addedAt for all seed keywords', () => {
56
+ vi.mocked(fs.existsSync).mockReturnValue(false);
57
+ const dir = tempDir();
58
+ const store = loadCorrectionKeywordStore(dir);
59
+ for (const kw of store.keywords) {
60
+ expect(kw.source).toBe('seed');
61
+ expect(kw.addedAt).not.toBe('');
62
+ expect(kw.addedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
63
+ }
64
+ });
65
+
66
+ it('should have all 16 exact terms from CORRECTION_SEED_KEYWORDS', () => {
67
+ vi.mocked(fs.existsSync).mockReturnValue(false);
68
+ const dir = tempDir();
69
+ const store = loadCorrectionKeywordStore(dir);
70
+ const terms = store.keywords.map((k) => k.term);
71
+ for (const seed of CORRECTION_SEED_KEYWORDS) {
72
+ expect(terms).toContain(seed.term);
73
+ }
74
+ });
75
+ });
76
+
77
+ // ═══════════════════════════════════════════════════════════════════════════════
78
+ // CORR-03: Atomic write
79
+ // ═══════════════════════════════════════════════════════════════════════════════
80
+
81
+ describe('CORR-03: Atomic write', () => {
82
+ it('should write to .tmp file before rename', () => {
83
+ vi.mocked(fs.existsSync).mockReturnValue(true);
84
+ vi.mocked(fs.readFileSync).mockReturnValue(
85
+ JSON.stringify({ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })), version: 1 })
86
+ );
87
+
88
+ const dir = tempDir();
89
+ const store = {
90
+ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })),
91
+ version: 1,
92
+ lastOptimizedAt: '2026-01-01T00:00:00Z',
93
+ };
94
+ saveCorrectionKeywordStore(dir, store);
95
+
96
+ const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
97
+ const tmpPath = writeCall[0] as string;
98
+ expect(tmpPath).toMatch(/\.tmp$/);
99
+ });
100
+
101
+ it('should rename from tmp path to final path after write', () => {
102
+ vi.mocked(fs.existsSync).mockReturnValue(true);
103
+ vi.mocked(fs.readFileSync).mockReturnValue(
104
+ JSON.stringify({ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })), version: 1 })
105
+ );
106
+
107
+ const dir = tempDir();
108
+ const store = {
109
+ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })),
110
+ version: 1,
111
+ lastOptimizedAt: '2026-01-01T00:00:00Z',
112
+ };
113
+ saveCorrectionKeywordStore(dir, store);
114
+
115
+ const renameCalls = vi.mocked(fs.renameSync).mock.calls;
116
+ expect(renameCalls).toHaveLength(1);
117
+ const [from, to] = renameCalls[0];
118
+ expect(from).toMatch(/\.tmp$/);
119
+ expect(to).not.toMatch(/\.tmp$/);
120
+ });
121
+
122
+ it('should call mkdirSync with recursive:true before writing', () => {
123
+ vi.mocked(fs.existsSync).mockReturnValue(true);
124
+ vi.mocked(fs.readFileSync).mockReturnValue(
125
+ JSON.stringify({ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })), version: 1 })
126
+ );
127
+
128
+ const dir = tempDir();
129
+ const store = {
130
+ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })),
131
+ version: 1,
132
+ lastOptimizedAt: '2026-01-01T00:00:00Z',
133
+ };
134
+ saveCorrectionKeywordStore(dir, store);
135
+
136
+ expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledWith(dir, { recursive: true });
137
+ });
138
+ });
139
+
140
+ // ═══════════════════════════════════════════════════════════════════════════════
141
+ // CORR-04: Cache invalidation
142
+ // ═══════════════════════════════════════════════════════════════════════════════
143
+
144
+ describe('CORR-04: Cache invalidation', () => {
145
+ it('should invalidate cache after save so next load re-reads from disk', () => {
146
+ vi.mocked(fs.existsSync).mockReturnValue(true);
147
+ vi.mocked(fs.readFileSync).mockReturnValue(
148
+ JSON.stringify({
149
+ keywords: CORRECTION_SEED_KEYWORDS.map((k) => ({ ...k, addedAt: '2026-01-01T00:00:00Z' })),
150
+ version: 1,
151
+ })
152
+ );
153
+
154
+ const dir = tempDir();
155
+ loadCorrectionKeywordStore(dir);
156
+ expect(vi.mocked(fs.readFileSync)).toHaveBeenCalled();
157
+
158
+ const store = loadCorrectionKeywordStore(dir);
159
+ saveCorrectionKeywordStore(dir, store);
160
+
161
+ // After save, cache is null — next load must re-read. Verify by changing
162
+ // the mock return and confirming the new data is picked up.
163
+ vi.mocked(fs.readFileSync).mockClear();
164
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ keywords: [], version: 1 }));
165
+
166
+ const store2 = loadCorrectionKeywordStore(dir);
167
+ expect(vi.mocked(fs.readFileSync)).toHaveBeenCalled();
168
+ expect(store2.keywords).toHaveLength(0); // proves re-read happened
169
+ });
170
+ });
171
+
172
+ // ═══════════════════════════════════════════════════════════════════════════════
173
+ // CORR-05: 200-term limit
174
+ // ═══════════════════════════════════════════════════════════════════════════════
175
+
176
+ describe('CORR-05: 200-term limit', () => {
177
+ it('should throw when adding keyword beyond 200 terms', () => {
178
+ const keywords = Array.from({ length: 200 }, (_, i) => ({
179
+ term: `keyword-${i}`,
180
+ weight: 0.5,
181
+ source: 'seed' as const,
182
+ addedAt: '2026-01-01T00:00:00Z',
183
+ }));
184
+ vi.mocked(fs.existsSync).mockReturnValue(true);
185
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ keywords, version: 1 }));
186
+
187
+ const dir = tempDir();
188
+ const learner = new CorrectionCueLearner(dir);
189
+ expect(learner.getStore().keywords).toHaveLength(200);
190
+
191
+ expect(() => learner.add({ term: 'new-keyword', weight: 0.5, source: 'user' })).toThrow(
192
+ 'Correction keyword store limit reached (200 terms)'
193
+ );
194
+ });
195
+
196
+ it('should allow add when at 199 terms', () => {
197
+ const keywords = Array.from({ length: 199 }, (_, i) => ({
198
+ term: `keyword-${i}`,
199
+ weight: 0.5,
200
+ source: 'seed' as const,
201
+ addedAt: '2026-01-01T00:00:00Z',
202
+ }));
203
+ vi.mocked(fs.existsSync).mockReturnValue(true);
204
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ keywords, version: 1 }));
205
+
206
+ const dir = tempDir();
207
+ const learner = new CorrectionCueLearner(dir);
208
+ expect(learner.getStore().keywords).toHaveLength(199);
209
+
210
+ expect(() => learner.add({ term: 'new-keyword', weight: 0.5, source: 'user' })).not.toThrow();
211
+ });
212
+
213
+ it('should not modify store when add fails due to limit', () => {
214
+ const keywords = Array.from({ length: 200 }, (_, i) => ({
215
+ term: `keyword-${i}`,
216
+ weight: 0.5,
217
+ source: 'seed' as const,
218
+ addedAt: '2026-01-01T00:00:00Z',
219
+ }));
220
+ vi.mocked(fs.existsSync).mockReturnValue(true);
221
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ keywords, version: 1 }));
222
+
223
+ const dir = tempDir();
224
+ const learner = new CorrectionCueLearner(dir);
225
+ try {
226
+ learner.add({ term: 'new-keyword', weight: 0.5, source: 'user' });
227
+ } catch {
228
+ // expected
229
+ }
230
+
231
+ expect(learner.getStore().keywords).toHaveLength(200);
232
+ });
233
+ });
234
+
235
+ // ═══════════════════════════════════════════════════════════════════════════════
236
+ // CORR-11: Equivalence to detectCorrectionCue
237
+ // ═══════════════════════════════════════════════════════════════════════════════
238
+
239
+ describe('CORR-11: Equivalence to detectCorrectionCue', () => {
240
+ beforeEach(() => {
241
+ vi.mocked(fs.existsSync).mockReturnValue(false);
242
+ });
243
+
244
+ /**
245
+ * Reference implementation using find() — first match wins (same as detectCorrectionCue).
246
+ */
247
+ function detectCorrectionCueLegacy(text: string): string | null {
248
+ const normalized = text.trim().toLowerCase().replace(/[.,!?;:,。!?;:]/g, '');
249
+ const cues = CORRECTION_SEED_KEYWORDS.map((k) => k.term);
250
+ return cues.find((cue) => normalized.includes(cue)) ?? null;
251
+ }
252
+
253
+ /**
254
+ * Tests using first-match semantics: find() returns the FIRST keyword in the
255
+ * array whose term appears in the normalized text, not the longest match.
256
+ *
257
+ * Order of CORRECTION_SEED_KEYWORDS array (first 8 Chinese):
258
+ * '不是这个', '不对', '错了', '搞错了', '理解错了', '你理解错了', '重新来', '再试一次'
259
+ *
260
+ * So "我搞错了" → "错了" is found first (index 2) before "搞错了" (index 3).
261
+ * "你理解错了" → "错了" is found first (index 2) before "理解错了" (index 4) and "你理解错了" (index 5).
262
+ */
263
+ it.each([
264
+ // Chinese cases — note: first match wins
265
+ ['不是这个', '不是这个'], // exact match
266
+ ['你不对啊', '不对'], // first match is '不对' (index 1)
267
+ ['错了!', '错了'], // exact match (index 2)
268
+ ['我搞错了', '错了'], // '错了' appears first in array (index 2 < index 3)
269
+ ['你理解错了', '错了'], // '错了' appears first in array (index 2 < index 4)
270
+ ['重新来一遍', '重新来'], // exact match
271
+ ['再试一次行不行', '再试一次'], // exact match
272
+ // English cases
273
+ ['you are wrong', 'you are wrong'], // exact match
274
+ ['wrong file', 'wrong file'], // exact match
275
+ ['not this one', 'not this'], // exact match
276
+ ['redo it', 'redo'], // exact match (index 11)
277
+ ['try again', 'try again'], // exact match (index 12)
278
+ ['do it again', 'again'], // 'again' is index 13
279
+ ['please redo', 'redo'], // 'redo' found first (index 11 < index 14)
280
+ ['please try again', 'try again'], // 'try again' found first (index 12 < index 15)
281
+ ])('should match "%s" → "%s"', (text, expected) => {
282
+ vi.mocked(fs.existsSync).mockReturnValue(false);
283
+ const dir = tempDir();
284
+ const learner = new CorrectionCueLearner(dir);
285
+ const result = learner.match(text);
286
+ expect(result.matched).toBe(true);
287
+ expect(result.matchedTerms).toContain(expected);
288
+ expect(result.score).toBeGreaterThan(0);
289
+ });
290
+
291
+ it('should produce same result as legacy detectCorrectionCue for varied inputs', () => {
292
+ vi.mocked(fs.existsSync).mockReturnValue(false);
293
+ const dir = tempDir();
294
+ const learner = new CorrectionCueLearner(dir);
295
+
296
+ const cases = [
297
+ '这个可以,没问题',
298
+ '不对,应该是这样',
299
+ '你再试试这个方法',
300
+ 'nothing wrong here',
301
+ 'please be careful',
302
+ 'can you try again?',
303
+ 'I think you are wrong about this',
304
+ ];
305
+
306
+ for (const text of cases) {
307
+ const legacyResult = detectCorrectionCueLegacy(text);
308
+ const learnerResult = learner.match(text);
309
+
310
+ if (legacyResult !== null) {
311
+ expect(learnerResult.matched).toBe(true);
312
+ expect(learnerResult.matchedTerms).toContain(legacyResult);
313
+ expect(learnerResult.score).toBeGreaterThan(0);
314
+ } else {
315
+ expect(learnerResult.matched).toBe(false);
316
+ expect(learnerResult.matchedTerms).toEqual([]);
317
+ }
318
+ }
319
+ });
320
+
321
+ it('should match regardless of surrounding punctuation', () => {
322
+ vi.mocked(fs.existsSync).mockReturnValue(false);
323
+ const dir = tempDir();
324
+ const learner = new CorrectionCueLearner(dir);
325
+
326
+ const variations = ['不对', '不对!', '不对?', '。不对', '不对。', ' 不对 ', '不对啊'];
327
+ for (const text of variations) {
328
+ const result = learner.match(text);
329
+ expect(result.matched).toBe(true);
330
+ expect(result.matchedTerms).toContain('不对');
331
+ }
332
+ });
333
+
334
+ it('should return positive score when matched, 0 when not matched', () => {
335
+ vi.mocked(fs.existsSync).mockReturnValue(false);
336
+ const dir = tempDir();
337
+ const learner = new CorrectionCueLearner(dir);
338
+ expect(learner.match('不是这个').score).toBeGreaterThan(0);
339
+ expect(learner.match('这个可以').score).toBe(0);
340
+ });
341
+
342
+ it('should export MAX_CORRECTION_KEYWORDS = 200', () => {
343
+ expect(MAX_CORRECTION_KEYWORDS).toBe(200);
344
+ });
345
+ });