principles-disciple 1.34.2 → 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.
Files changed (35) hide show
  1. package/.dependency-cruiser.json +19 -0
  2. package/openclaw.plugin.json +1 -1
  3. package/package.json +6 -3
  4. package/src/config/defaults/runtime.ts +100 -24
  5. package/src/core/correction-cue-learner.ts +23 -8
  6. package/src/core/event-log.ts +87 -20
  7. package/src/core/init.ts +2 -2
  8. package/src/core/nocturnal-candidate-scoring.ts +6 -6
  9. package/src/core/nocturnal-trinity-types.ts +94 -0
  10. package/src/core/nocturnal-trinity.ts +35 -99
  11. package/src/core/session-tracker.ts +7 -6
  12. package/src/core/system-logger.ts +104 -12
  13. package/src/core/workspace-dir-service.ts +40 -6
  14. package/src/core/workspace-dir-validation.ts +5 -37
  15. package/src/hooks/prompt.ts +3 -3
  16. package/src/hooks/trajectory-collector.ts +7 -7
  17. package/src/index.ts +8 -68
  18. package/src/service/central-sync-service.ts +3 -8
  19. package/src/service/correction-observer-workflow-manager.ts +2 -2
  20. package/src/service/evolution-worker.ts +13 -22
  21. package/src/service/keyword-optimization-service.ts +2 -2
  22. package/src/service/nocturnal-service.ts +62 -43
  23. package/src/service/subagent-workflow/correction-observer-types.ts +69 -0
  24. package/src/service/subagent-workflow/correction-observer-workflow-manager.ts +246 -0
  25. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +4 -4
  26. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +4 -4
  27. package/src/service/subagent-workflow/index.ts +13 -0
  28. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +2 -2
  29. package/src/service/subagent-workflow/types.ts +69 -3
  30. package/src/utils/shadow-fingerprint.ts +42 -0
  31. package/src/utils/workspace-resolver.ts +54 -0
  32. package/tests/core/correction-cue-learner.test.ts +345 -0
  33. package/tests/core/workspace-dir-validation.test.ts +1 -1
  34. package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +3 -3
  35. package/vitest.config.ts +53 -6
@@ -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
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import * as os from 'os';
3
- import { validateWorkspaceDir, resolveValidWorkspaceDir, logWorkspaceDirHealth } from '../../src/core/workspace-dir-validation.js';
3
+ import { validateWorkspaceDir, resolveValidWorkspaceDir, logWorkspaceDirHealth } from '../../src/core/workspace-dir-service.js';
4
4
 
5
5
  const homeDir = os.homedir();
6
6
 
@@ -65,7 +65,7 @@ describe('E2E: Tool Hooks workspaceDir Resolution', () => {
65
65
 
66
66
  describe('Scenario 2: ctx.workspaceDir is undefined (current OpenClaw behavior)', () => {
67
67
  it('should fallback to agentId resolution', async () => {
68
- const { resolveValidWorkspaceDir } = await import('../../src/core/workspace-dir-validation.js');
68
+ const { resolveValidWorkspaceDir } = await import('../../src/core/workspace-dir-service.js');
69
69
 
70
70
  const mockApi = createMockApi(testWorkspaceDir);
71
71
  const ctx = {
@@ -80,7 +80,7 @@ describe('E2E: Tool Hooks workspaceDir Resolution', () => {
80
80
  });
81
81
 
82
82
  it('should refuse to guess a workspace when agentId is also undefined', async () => {
83
- const { resolveValidWorkspaceDir } = await import('../../src/core/workspace-dir-validation.js');
83
+ const { resolveValidWorkspaceDir } = await import('../../src/core/workspace-dir-service.js');
84
84
 
85
85
  const mockApi = createMockApi(testWorkspaceDir);
86
86
  const ctx = {
@@ -131,7 +131,7 @@ describe('E2E: Tool Hooks workspaceDir Resolution', () => {
131
131
 
132
132
  describe('Scenario 4: Invalid workspace candidates are rejected', () => {
133
133
  it('should return undefined when all workspace resolution candidates are invalid', async () => {
134
- const { resolveValidWorkspaceDir } = await import('../../src/core/workspace-dir-validation.js');
134
+ const { resolveValidWorkspaceDir } = await import('../../src/core/workspace-dir-service.js');
135
135
 
136
136
  const mockApi = createMockApi(os.homedir());
137
137
  mockApi.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(os.homedir());
package/vitest.config.ts CHANGED
@@ -1,12 +1,41 @@
1
1
  import { defineConfig } from 'vitest/config';
2
2
 
3
+ /**
4
+ * Vitest configuration with test layering
5
+ *
6
+ * LAYERS:
7
+ * - unit: Mock-based tests, no real DB (fast, parallel)
8
+ * - integration: Tests using real SQLite DB (requires threads pool)
9
+ *
10
+ * USAGE:
11
+ * - npm test → run all tests
12
+ * - npm run test:unit → run unit tests only (fast)
13
+ * - npm run test:integration → run integration tests only
14
+ *
15
+ * WHY threads pool?
16
+ * better-sqlite3 native handles don't clean up properly in fork subprocesses,
17
+ * causing teardown hangs. Threads pool handles this correctly.
18
+ */
19
+
20
+ // Integration tests: use real SQLite database
21
+ const integrationTests = [
22
+ 'tests/core/control-ui-db.test.ts',
23
+ 'tests/core/evolution-logger.test.ts',
24
+ 'tests/core/nocturnal-e2e.test.ts',
25
+ 'tests/core/nocturnal-trajectory-extractor.test.ts',
26
+ 'tests/core/replay-engine.test.ts',
27
+ 'tests/core/trajectory.test.ts',
28
+ 'tests/integration/**/*.test.ts',
29
+ 'tests/integration/**/*.test.tsx',
30
+ 'tests/service/nocturnal-service-code-candidate.test.ts',
31
+ 'tests/service/nocturnal-target-selector.test.ts',
32
+ ];
33
+
3
34
  export default defineConfig({
4
35
  test: {
5
36
  environment: 'node',
6
37
  include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
7
- // vitest 4: pool: 'forks' 默认启用隔离,每个测试文件在独立进程中运行
8
- pool: 'forks',
9
- // 确保测试完成后进程能正常退出
38
+ pool: 'threads',
10
39
  teardownTimeout: 15000,
11
40
  coverage: {
12
41
  provider: 'v8',
@@ -18,6 +47,24 @@ export default defineConfig({
18
47
  branches: 60,
19
48
  statements: 70,
20
49
  },
21
- }
22
- }
23
- });
50
+ },
51
+ // Workspace projects for layered testing
52
+ projects: [
53
+ {
54
+ test: {
55
+ name: 'unit',
56
+ include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
57
+ exclude: integrationTests,
58
+ pool: 'threads',
59
+ },
60
+ },
61
+ {
62
+ test: {
63
+ name: 'integration',
64
+ include: integrationTests,
65
+ pool: 'threads',
66
+ },
67
+ },
68
+ ],
69
+ },
70
+ });