@liangjie559567/ultrapower 5.1.0 → 5.2.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/.claude-plugin/plugin.json +1 -1
- package/dist/__tests__/skills.test.js +3 -2
- package/dist/__tests__/skills.test.js.map +1 -1
- package/dist/hooks/nexus/__tests__/config.test.d.ts +2 -0
- package/dist/hooks/nexus/__tests__/config.test.d.ts.map +1 -0
- package/dist/hooks/nexus/__tests__/config.test.js +38 -0
- package/dist/hooks/nexus/__tests__/config.test.js.map +1 -0
- package/dist/hooks/nexus/__tests__/consciousness-sync.test.d.ts +2 -0
- package/dist/hooks/nexus/__tests__/consciousness-sync.test.d.ts.map +1 -0
- package/dist/hooks/nexus/__tests__/consciousness-sync.test.js +28 -0
- package/dist/hooks/nexus/__tests__/consciousness-sync.test.js.map +1 -0
- package/dist/hooks/nexus/__tests__/data-collector.test.d.ts +2 -0
- package/dist/hooks/nexus/__tests__/data-collector.test.d.ts.map +1 -0
- package/dist/hooks/nexus/__tests__/data-collector.test.js +61 -0
- package/dist/hooks/nexus/__tests__/data-collector.test.js.map +1 -0
- package/dist/hooks/nexus/__tests__/improvement-puller.test.d.ts +2 -0
- package/dist/hooks/nexus/__tests__/improvement-puller.test.d.ts.map +1 -0
- package/dist/hooks/nexus/__tests__/improvement-puller.test.js +49 -0
- package/dist/hooks/nexus/__tests__/improvement-puller.test.js.map +1 -0
- package/dist/hooks/nexus/__tests__/session-end-hook.test.d.ts +2 -0
- package/dist/hooks/nexus/__tests__/session-end-hook.test.d.ts.map +1 -0
- package/dist/hooks/nexus/__tests__/session-end-hook.test.js +39 -0
- package/dist/hooks/nexus/__tests__/session-end-hook.test.js.map +1 -0
- package/dist/hooks/nexus/config.d.ts +5 -0
- package/dist/hooks/nexus/config.d.ts.map +1 -0
- package/dist/hooks/nexus/config.js +35 -0
- package/dist/hooks/nexus/config.js.map +1 -0
- package/dist/hooks/nexus/consciousness-sync.d.ts +8 -0
- package/dist/hooks/nexus/consciousness-sync.d.ts.map +1 -0
- package/dist/hooks/nexus/consciousness-sync.js +57 -0
- package/dist/hooks/nexus/consciousness-sync.js.map +1 -0
- package/dist/hooks/nexus/data-collector.d.ts +4 -0
- package/dist/hooks/nexus/data-collector.d.ts.map +1 -0
- package/dist/hooks/nexus/data-collector.js +23 -0
- package/dist/hooks/nexus/data-collector.js.map +1 -0
- package/dist/hooks/nexus/improvement-puller.d.ts +9 -0
- package/dist/hooks/nexus/improvement-puller.d.ts.map +1 -0
- package/dist/hooks/nexus/improvement-puller.js +42 -0
- package/dist/hooks/nexus/improvement-puller.js.map +1 -0
- package/dist/hooks/nexus/session-end-hook.d.ts +16 -0
- package/dist/hooks/nexus/session-end-hook.d.ts.map +1 -0
- package/dist/hooks/nexus/session-end-hook.js +49 -0
- package/dist/hooks/nexus/session-end-hook.js.map +1 -0
- package/dist/hooks/nexus/types.d.ts +54 -0
- package/dist/hooks/nexus/types.d.ts.map +1 -0
- package/dist/hooks/nexus/types.js +2 -0
- package/dist/hooks/nexus/types.js.map +1 -0
- package/dist/hooks/session-end/__tests__/nexus-integration.test.d.ts +2 -0
- package/dist/hooks/session-end/__tests__/nexus-integration.test.d.ts.map +1 -0
- package/dist/hooks/session-end/__tests__/nexus-integration.test.js +30 -0
- package/dist/hooks/session-end/__tests__/nexus-integration.test.js.map +1 -0
- package/dist/hooks/session-end/index.d.ts.map +1 -1
- package/dist/hooks/session-end/index.js +15 -0
- package/dist/hooks/session-end/index.js.map +1 -1
- package/docs/CLAUDE.md +1 -1
- package/docs/README.codex.md +6 -0
- package/docs/plans/2026-01-17-visual-brainstorming.md +571 -0
- package/docs/plans/2026-02-26-nexus-design.md +354 -0
- package/docs/plans/2026-02-26-nexus-implementation-plan.md +2538 -0
- package/docs/plans/2026-02-26-phase2-active-learning-design.md +377 -0
- package/docs/standards/contribution-guide.md +52 -1
- package/docs/superpowers/plans/2026-01-22-document-review-system.md +301 -0
- package/docs/superpowers/plans/2026-02-19-visual-brainstorming-refactor.md +523 -0
- package/docs/superpowers/specs/2026-01-22-document-review-system-design.md +136 -0
- package/docs/superpowers/specs/2026-02-19-visual-brainstorming-refactor-design.md +162 -0
- package/hooks/run-hook.cmd +32 -29
- package/package.json +4 -2
- package/skills/brainstorming/spec-document-reviewer-prompt.md +50 -0
- package/skills/brainstorming/visual-companion.md +249 -0
- package/skills/nexus/SKILL.md +35 -0
- package/skills/nexus/nexus-evolve/SKILL.md +31 -0
- package/skills/nexus/nexus-review/SKILL.md +39 -0
- package/skills/nexus/nexus-status/SKILL.md +31 -0
- package/skills/release/SKILL.md +3 -0
- package/skills/requesting-code-review/SKILL.md +1 -1
- package/skills/using-superpowers/references/codex-tools.md +25 -0
- package/skills/writing-plans/plan-document-reviewer-prompt.md +52 -0
- /package/hooks/{session-start.sh → session-start} +0 -0
|
@@ -0,0 +1,2538 @@
|
|
|
1
|
+
# nexus 实现计划
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** 为 ultrapower 添加 nexus 双层自我提升系统——插件层(TypeScript hooks)+ VPS 守护进程(Python)
|
|
6
|
+
|
|
7
|
+
**Architecture:** 插件层通过 Git 同步将会话数据推送到 nexus-daemon 仓库;VPS 守护进程每分钟拉取新事件,运行进化引擎和意识循环,生成改进建议推回仓库;插件层拉取改进并按置信度自动或通过 Telegram 确认应用。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript (plugin hooks), Python 3.11 (VPS daemon), Git sync (communication), Telegram Bot API (notifications), systemd (process management)
|
|
10
|
+
|
|
11
|
+
**Design Doc:** `docs/plans/2026-02-26-nexus-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## P0:核心基础设施
|
|
16
|
+
|
|
17
|
+
### Task 1: nexus 配置类型和存储结构
|
|
18
|
+
|
|
19
|
+
**Files:**
|
|
20
|
+
- Create: `src/hooks/nexus/types.ts`
|
|
21
|
+
- Create: `src/hooks/nexus/config.ts`
|
|
22
|
+
- Create: `src/hooks/nexus/__tests__/config.test.ts`
|
|
23
|
+
|
|
24
|
+
**Step 1: 写失败测试**
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
// src/hooks/nexus/__tests__/config.test.ts
|
|
28
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
29
|
+
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { tmpdir } from 'os';
|
|
32
|
+
import { loadNexusConfig, DEFAULT_NEXUS_CONFIG } from '../config.js';
|
|
33
|
+
|
|
34
|
+
describe('loadNexusConfig', () => {
|
|
35
|
+
let tmpDir: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
tmpDir = join(tmpdir(), `nexus-test-${Date.now()}`);
|
|
39
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns default config when file does not exist', () => {
|
|
47
|
+
const config = loadNexusConfig(tmpDir);
|
|
48
|
+
expect(config.enabled).toBe(false);
|
|
49
|
+
expect(config.autoApplyThreshold).toBe(80);
|
|
50
|
+
expect(config.consciousnessInterval).toBe(300);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('loads and merges config from .omc/nexus/config.json', () => {
|
|
54
|
+
const configDir = join(tmpDir, '.omc', 'nexus');
|
|
55
|
+
mkdirSync(configDir, { recursive: true });
|
|
56
|
+
writeFileSync(
|
|
57
|
+
join(configDir, 'config.json'),
|
|
58
|
+
JSON.stringify({ enabled: true, gitRemote: 'git@github.com:user/nexus-daemon.git' })
|
|
59
|
+
);
|
|
60
|
+
const config = loadNexusConfig(tmpDir);
|
|
61
|
+
expect(config.enabled).toBe(true);
|
|
62
|
+
expect(config.gitRemote).toBe('git@github.com:user/nexus-daemon.git');
|
|
63
|
+
expect(config.autoApplyThreshold).toBe(80); // default preserved
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns default config on malformed JSON', () => {
|
|
67
|
+
const configDir = join(tmpDir, '.omc', 'nexus');
|
|
68
|
+
mkdirSync(configDir, { recursive: true });
|
|
69
|
+
writeFileSync(join(configDir, 'config.json'), 'not json');
|
|
70
|
+
const config = loadNexusConfig(tmpDir);
|
|
71
|
+
expect(config.enabled).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Step 2: 运行测试确认失败**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npm test -- src/hooks/nexus/__tests__/config.test.ts
|
|
80
|
+
```
|
|
81
|
+
Expected: FAIL — `Cannot find module '../config.js'`
|
|
82
|
+
|
|
83
|
+
**Step 3: 实现 types.ts**
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// src/hooks/nexus/types.ts
|
|
87
|
+
export interface NexusConfig {
|
|
88
|
+
enabled: boolean;
|
|
89
|
+
gitRemote: string;
|
|
90
|
+
telegramToken?: string;
|
|
91
|
+
telegramChatId?: string;
|
|
92
|
+
autoApplyThreshold: number; // 0-100, default 80
|
|
93
|
+
consciousnessInterval: number; // seconds, default 300
|
|
94
|
+
consciousnessBudgetPercent: number; // 0-100, default 10
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SessionEvent {
|
|
98
|
+
sessionId: string;
|
|
99
|
+
timestamp: string;
|
|
100
|
+
directory: string;
|
|
101
|
+
durationMs?: number;
|
|
102
|
+
toolCalls: ToolCallRecord[];
|
|
103
|
+
agentsSpawned: number;
|
|
104
|
+
agentsCompleted: number;
|
|
105
|
+
modesUsed: string[];
|
|
106
|
+
skillsInjected: string[];
|
|
107
|
+
patternsSeen: PatternRecord[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ToolCallRecord {
|
|
111
|
+
toolName: string;
|
|
112
|
+
agentRole?: string;
|
|
113
|
+
skillName?: string;
|
|
114
|
+
timestamp: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface PatternRecord {
|
|
118
|
+
problem: string;
|
|
119
|
+
solution: string;
|
|
120
|
+
confidence: number;
|
|
121
|
+
occurrences: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ImprovementSuggestion {
|
|
125
|
+
id: string;
|
|
126
|
+
createdAt: string;
|
|
127
|
+
source: 'evolution_engine' | 'consciousness_loop' | 'self_evaluator';
|
|
128
|
+
type: 'skill_update' | 'agent_update' | 'hook_update' | 'trigger_update';
|
|
129
|
+
targetFile: string;
|
|
130
|
+
confidence: number;
|
|
131
|
+
diff: string;
|
|
132
|
+
reason: string;
|
|
133
|
+
evidence: Record<string, unknown>;
|
|
134
|
+
status: 'pending' | 'applied' | 'rejected' | 'failed';
|
|
135
|
+
testResult: string | null;
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Step 4: 实现 config.ts**
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// src/hooks/nexus/config.ts
|
|
143
|
+
import { existsSync, readFileSync } from 'fs';
|
|
144
|
+
import { join } from 'path';
|
|
145
|
+
import type { NexusConfig } from './types.js';
|
|
146
|
+
|
|
147
|
+
export const DEFAULT_NEXUS_CONFIG: NexusConfig = {
|
|
148
|
+
enabled: false,
|
|
149
|
+
gitRemote: '',
|
|
150
|
+
autoApplyThreshold: 80,
|
|
151
|
+
consciousnessInterval: 300,
|
|
152
|
+
consciousnessBudgetPercent: 10,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const CONFIG_RELATIVE_PATH = '.omc/nexus/config.json';
|
|
156
|
+
|
|
157
|
+
export function loadNexusConfig(directory: string): NexusConfig {
|
|
158
|
+
const configPath = join(directory, CONFIG_RELATIVE_PATH);
|
|
159
|
+
if (!existsSync(configPath)) {
|
|
160
|
+
return { ...DEFAULT_NEXUS_CONFIG };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
164
|
+
const partial = JSON.parse(raw) as Partial<NexusConfig>;
|
|
165
|
+
return { ...DEFAULT_NEXUS_CONFIG, ...partial };
|
|
166
|
+
} catch {
|
|
167
|
+
return { ...DEFAULT_NEXUS_CONFIG };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function isNexusEnabled(directory: string): boolean {
|
|
172
|
+
return loadNexusConfig(directory).enabled;
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Step 5: 运行测试确认通过**
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm test -- src/hooks/nexus/__tests__/config.test.ts
|
|
180
|
+
```
|
|
181
|
+
Expected: PASS (3 tests)
|
|
182
|
+
|
|
183
|
+
**Step 6: Commit**
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
git add src/hooks/nexus/types.ts src/hooks/nexus/config.ts src/hooks/nexus/__tests__/config.test.ts
|
|
187
|
+
git commit -m "feat(nexus): add config types and loader"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
### Task 2: data-collector hook(收集会话数据)
|
|
193
|
+
|
|
194
|
+
**Files:**
|
|
195
|
+
- Create: `src/hooks/nexus/data-collector.ts`
|
|
196
|
+
- Create: `src/hooks/nexus/__tests__/data-collector.test.ts`
|
|
197
|
+
|
|
198
|
+
**Step 1: 写失败测试**
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// src/hooks/nexus/__tests__/data-collector.test.ts
|
|
202
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
203
|
+
import { mkdirSync, rmSync, existsSync, readFileSync, readdirSync } from 'fs';
|
|
204
|
+
import { join } from 'path';
|
|
205
|
+
import { tmpdir } from 'os';
|
|
206
|
+
import { collectSessionEvent, getEventsDir } from '../data-collector.js';
|
|
207
|
+
import type { SessionEvent } from '../types.js';
|
|
208
|
+
|
|
209
|
+
describe('collectSessionEvent', () => {
|
|
210
|
+
let tmpDir: string;
|
|
211
|
+
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
tmpDir = join(tmpdir(), `nexus-dc-test-${Date.now()}`);
|
|
214
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
215
|
+
// Enable nexus in config
|
|
216
|
+
const configDir = join(tmpDir, '.omc', 'nexus');
|
|
217
|
+
mkdirSync(configDir, { recursive: true });
|
|
218
|
+
writeFileSync(join(configDir, 'config.json'), JSON.stringify({ enabled: true }));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
afterEach(() => {
|
|
222
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('writes event JSON to .omc/nexus/events/', async () => {
|
|
226
|
+
const event: SessionEvent = {
|
|
227
|
+
sessionId: 'test-session-123',
|
|
228
|
+
timestamp: '2026-02-26T14:00:00Z',
|
|
229
|
+
directory: tmpDir,
|
|
230
|
+
toolCalls: [],
|
|
231
|
+
agentsSpawned: 2,
|
|
232
|
+
agentsCompleted: 2,
|
|
233
|
+
modesUsed: ['ultrawork'],
|
|
234
|
+
skillsInjected: [],
|
|
235
|
+
patternsSeen: [],
|
|
236
|
+
};
|
|
237
|
+
await collectSessionEvent(tmpDir, event);
|
|
238
|
+
const eventsDir = getEventsDir(tmpDir);
|
|
239
|
+
expect(existsSync(eventsDir)).toBe(true);
|
|
240
|
+
const files = readdirSync(eventsDir);
|
|
241
|
+
expect(files.length).toBe(1);
|
|
242
|
+
expect(files[0]).toMatch(/^test-session-123-\d+\.json$/);
|
|
243
|
+
const content = JSON.parse(readFileSync(join(eventsDir, files[0]), 'utf-8'));
|
|
244
|
+
expect(content.sessionId).toBe('test-session-123');
|
|
245
|
+
expect(content.agentsSpawned).toBe(2);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('does nothing when nexus is disabled', async () => {
|
|
249
|
+
const disabledDir = join(tmpdir(), `nexus-disabled-${Date.now()}`);
|
|
250
|
+
mkdirSync(disabledDir, { recursive: true });
|
|
251
|
+
const event: SessionEvent = {
|
|
252
|
+
sessionId: 'test-session-456',
|
|
253
|
+
timestamp: '2026-02-26T14:00:00Z',
|
|
254
|
+
directory: disabledDir,
|
|
255
|
+
toolCalls: [],
|
|
256
|
+
agentsSpawned: 0,
|
|
257
|
+
agentsCompleted: 0,
|
|
258
|
+
modesUsed: [],
|
|
259
|
+
skillsInjected: [],
|
|
260
|
+
patternsSeen: [],
|
|
261
|
+
};
|
|
262
|
+
await collectSessionEvent(disabledDir, event);
|
|
263
|
+
const eventsDir = getEventsDir(disabledDir);
|
|
264
|
+
expect(existsSync(eventsDir)).toBe(false);
|
|
265
|
+
rmSync(disabledDir, { recursive: true, force: true });
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Step 2: 运行测试确认失败**
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
npm test -- src/hooks/nexus/__tests__/data-collector.test.ts
|
|
274
|
+
```
|
|
275
|
+
Expected: FAIL — `Cannot find module '../data-collector.js'`
|
|
276
|
+
|
|
277
|
+
**Step 3: 实现 data-collector.ts**
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
// src/hooks/nexus/data-collector.ts
|
|
281
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
282
|
+
import { join } from 'path';
|
|
283
|
+
import { isNexusEnabled } from './config.js';
|
|
284
|
+
import type { SessionEvent } from './types.js';
|
|
285
|
+
|
|
286
|
+
const EVENTS_SUBDIR = '.omc/nexus/events';
|
|
287
|
+
|
|
288
|
+
export function getEventsDir(directory: string): string {
|
|
289
|
+
return join(directory, EVENTS_SUBDIR);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function collectSessionEvent(
|
|
293
|
+
directory: string,
|
|
294
|
+
event: SessionEvent
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
try {
|
|
297
|
+
if (!isNexusEnabled(directory)) return;
|
|
298
|
+
|
|
299
|
+
const eventsDir = getEventsDir(directory);
|
|
300
|
+
mkdirSync(eventsDir, { recursive: true });
|
|
301
|
+
|
|
302
|
+
const timestamp = Date.now();
|
|
303
|
+
const filename = `${event.sessionId}-${timestamp}.json`;
|
|
304
|
+
const filePath = join(eventsDir, filename);
|
|
305
|
+
|
|
306
|
+
writeFileSync(filePath, JSON.stringify(event, null, 2), 'utf-8');
|
|
307
|
+
} catch {
|
|
308
|
+
// Silent failure — must never break main flow
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**Step 4: 运行测试确认通过**
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
npm test -- src/hooks/nexus/__tests__/data-collector.test.ts
|
|
317
|
+
```
|
|
318
|
+
Expected: PASS (2 tests)
|
|
319
|
+
|
|
320
|
+
**Step 5: Commit**
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
git add src/hooks/nexus/data-collector.ts src/hooks/nexus/__tests__/data-collector.test.ts
|
|
324
|
+
git commit -m "feat(nexus): add data-collector for session events"
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
### Task 3: consciousness-sync hook(SessionEnd 后 git push)
|
|
330
|
+
|
|
331
|
+
**Files:**
|
|
332
|
+
- Create: `src/hooks/nexus/consciousness-sync.ts`
|
|
333
|
+
- Create: `src/hooks/nexus/__tests__/consciousness-sync.test.ts`
|
|
334
|
+
|
|
335
|
+
**Step 1: 写失败测试**
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
// src/hooks/nexus/__tests__/consciousness-sync.test.ts
|
|
339
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
340
|
+
import { syncToRemote, buildGitCommitMessage } from '../consciousness-sync.js';
|
|
341
|
+
|
|
342
|
+
describe('buildGitCommitMessage', () => {
|
|
343
|
+
it('includes session ID in commit message', () => {
|
|
344
|
+
const msg = buildGitCommitMessage('abc123', 3);
|
|
345
|
+
expect(msg).toContain('abc123');
|
|
346
|
+
expect(msg).toContain('3');
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('syncToRemote', () => {
|
|
351
|
+
it('returns false when nexus is disabled', async () => {
|
|
352
|
+
const result = await syncToRemote('/nonexistent/dir', 'sess-001');
|
|
353
|
+
expect(result.success).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**Step 2: 运行测试确认失败**
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
npm test -- src/hooks/nexus/__tests__/consciousness-sync.test.ts
|
|
362
|
+
```
|
|
363
|
+
Expected: FAIL — `Cannot find module '../consciousness-sync.js'`
|
|
364
|
+
|
|
365
|
+
**Step 3: 实现 consciousness-sync.ts**
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
// src/hooks/nexus/consciousness-sync.ts
|
|
369
|
+
import { spawnSync } from 'child_process';
|
|
370
|
+
import { existsSync } from 'fs';
|
|
371
|
+
import { join } from 'path';
|
|
372
|
+
import { isNexusEnabled, loadNexusConfig } from './config.js';
|
|
373
|
+
import { getEventsDir } from './data-collector.js';
|
|
374
|
+
|
|
375
|
+
export interface SyncResult {
|
|
376
|
+
success: boolean;
|
|
377
|
+
error?: string;
|
|
378
|
+
filesCommitted?: number;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function buildGitCommitMessage(sessionId: string, fileCount: number): string {
|
|
382
|
+
// Use only first 8 chars of sessionId (alphanumeric only) to avoid injection
|
|
383
|
+
const safeId = sessionId.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 8);
|
|
384
|
+
return `nexus: sync ${fileCount} event(s) from session ${safeId}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function git(args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } {
|
|
388
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf-8' });
|
|
389
|
+
return {
|
|
390
|
+
ok: result.status === 0,
|
|
391
|
+
stdout: result.stdout ?? '',
|
|
392
|
+
stderr: result.stderr ?? '',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function syncToRemote(
|
|
397
|
+
directory: string,
|
|
398
|
+
sessionId: string
|
|
399
|
+
): Promise<SyncResult> {
|
|
400
|
+
try {
|
|
401
|
+
if (!isNexusEnabled(directory)) {
|
|
402
|
+
return { success: false, error: 'nexus disabled' };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const config = loadNexusConfig(directory);
|
|
406
|
+
if (!config.gitRemote) {
|
|
407
|
+
return { success: false, error: 'gitRemote not configured' };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const eventsDir = getEventsDir(directory);
|
|
411
|
+
if (!existsSync(eventsDir)) {
|
|
412
|
+
return { success: true, filesCommitted: 0 };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Stage only nexus events — args are array, no shell injection possible
|
|
416
|
+
git(['add', join('.omc', 'nexus', 'events')], directory);
|
|
417
|
+
|
|
418
|
+
// Check if there's anything to commit
|
|
419
|
+
const statusResult = git(['status', '--porcelain'], directory);
|
|
420
|
+
const nexusLines = statusResult.stdout.split('\n').filter(l => l.includes('.omc/nexus/events'));
|
|
421
|
+
if (nexusLines.length === 0) {
|
|
422
|
+
return { success: true, filesCommitted: 0 };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const msg = buildGitCommitMessage(sessionId, nexusLines.length);
|
|
426
|
+
const commitResult = git(['commit', '-m', msg], directory);
|
|
427
|
+
if (!commitResult.ok) {
|
|
428
|
+
return { success: false, error: commitResult.stderr };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const pushResult = git(['push', 'origin', 'HEAD'], directory);
|
|
432
|
+
if (!pushResult.ok) {
|
|
433
|
+
return { success: false, error: pushResult.stderr };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { success: true, filesCommitted: nexusLines.length };
|
|
437
|
+
} catch (e) {
|
|
438
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
**Step 4: 运行测试确认通过**
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
npm test -- src/hooks/nexus/__tests__/consciousness-sync.test.ts
|
|
447
|
+
```
|
|
448
|
+
Expected: PASS (3 tests)
|
|
449
|
+
|
|
450
|
+
**Step 5: Commit**
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
git add src/hooks/nexus/consciousness-sync.ts src/hooks/nexus/__tests__/consciousness-sync.test.ts
|
|
454
|
+
git commit -m "feat(nexus): add consciousness-sync for git push on session end"
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
### Task 4: SessionEnd hook 集成(连接 data-collector + consciousness-sync)
|
|
460
|
+
|
|
461
|
+
**Files:**
|
|
462
|
+
- Create: `src/hooks/nexus/session-end-hook.ts`
|
|
463
|
+
- Modify: `src/hooks/index.ts`(注册新 hook)
|
|
464
|
+
|
|
465
|
+
**Step 1: 写失败测试**
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
// src/hooks/nexus/__tests__/session-end-hook.test.ts
|
|
469
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
470
|
+
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
471
|
+
import { join } from 'path';
|
|
472
|
+
import { tmpdir } from 'os';
|
|
473
|
+
import { handleNexusSessionEnd } from '../session-end-hook.js';
|
|
474
|
+
|
|
475
|
+
describe('handleNexusSessionEnd', () => {
|
|
476
|
+
let tmpDir: string;
|
|
477
|
+
|
|
478
|
+
beforeEach(() => {
|
|
479
|
+
tmpDir = join(tmpdir(), `nexus-se-test-${Date.now()}`);
|
|
480
|
+
mkdirSync(join(tmpDir, '.omc', 'nexus'), { recursive: true });
|
|
481
|
+
writeFileSync(
|
|
482
|
+
join(tmpDir, '.omc', 'nexus', 'config.json'),
|
|
483
|
+
JSON.stringify({ enabled: true, gitRemote: '' }) // no remote = no push
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
afterEach(() => {
|
|
488
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('collects event and returns result', async () => {
|
|
492
|
+
const result = await handleNexusSessionEnd({
|
|
493
|
+
sessionId: 'test-sess-789',
|
|
494
|
+
directory: tmpDir,
|
|
495
|
+
durationMs: 60000,
|
|
496
|
+
agentsSpawned: 1,
|
|
497
|
+
agentsCompleted: 1,
|
|
498
|
+
modesUsed: ['ultrawork'],
|
|
499
|
+
});
|
|
500
|
+
expect(result.collected).toBe(true);
|
|
501
|
+
// sync skipped because gitRemote is empty
|
|
502
|
+
expect(result.synced).toBe(false);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('returns not collected when nexus disabled', async () => {
|
|
506
|
+
writeFileSync(
|
|
507
|
+
join(tmpDir, '.omc', 'nexus', 'config.json'),
|
|
508
|
+
JSON.stringify({ enabled: false })
|
|
509
|
+
);
|
|
510
|
+
const result = await handleNexusSessionEnd({
|
|
511
|
+
sessionId: 'test-sess-000',
|
|
512
|
+
directory: tmpDir,
|
|
513
|
+
});
|
|
514
|
+
expect(result.collected).toBe(false);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
**Step 2: 运行测试确认失败**
|
|
520
|
+
|
|
521
|
+
```bash
|
|
522
|
+
npm test -- src/hooks/nexus/__tests__/session-end-hook.test.ts
|
|
523
|
+
```
|
|
524
|
+
Expected: FAIL — `Cannot find module '../session-end-hook.js'`
|
|
525
|
+
|
|
526
|
+
**Step 3: 实现 session-end-hook.ts**
|
|
527
|
+
|
|
528
|
+
```typescript
|
|
529
|
+
// src/hooks/nexus/session-end-hook.ts
|
|
530
|
+
import { isNexusEnabled } from './config.js';
|
|
531
|
+
import { collectSessionEvent } from './data-collector.js';
|
|
532
|
+
import { syncToRemote } from './consciousness-sync.js';
|
|
533
|
+
import type { SessionEvent } from './types.js';
|
|
534
|
+
|
|
535
|
+
export interface NexusSessionEndInput {
|
|
536
|
+
sessionId: string;
|
|
537
|
+
directory: string;
|
|
538
|
+
durationMs?: number;
|
|
539
|
+
agentsSpawned?: number;
|
|
540
|
+
agentsCompleted?: number;
|
|
541
|
+
modesUsed?: string[];
|
|
542
|
+
skillsInjected?: string[];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export interface NexusSessionEndResult {
|
|
546
|
+
collected: boolean;
|
|
547
|
+
synced: boolean;
|
|
548
|
+
error?: string;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export async function handleNexusSessionEnd(
|
|
552
|
+
input: NexusSessionEndInput
|
|
553
|
+
): Promise<NexusSessionEndResult> {
|
|
554
|
+
const work = async (): Promise<NexusSessionEndResult> => {
|
|
555
|
+
try {
|
|
556
|
+
if (!isNexusEnabled(input.directory)) {
|
|
557
|
+
return { collected: false, synced: false };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const event: SessionEvent = {
|
|
561
|
+
sessionId: input.sessionId,
|
|
562
|
+
timestamp: new Date().toISOString(),
|
|
563
|
+
directory: input.directory,
|
|
564
|
+
durationMs: input.durationMs,
|
|
565
|
+
toolCalls: [],
|
|
566
|
+
agentsSpawned: input.agentsSpawned ?? 0,
|
|
567
|
+
agentsCompleted: input.agentsCompleted ?? 0,
|
|
568
|
+
modesUsed: input.modesUsed ?? [],
|
|
569
|
+
skillsInjected: input.skillsInjected ?? [],
|
|
570
|
+
patternsSeen: [],
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
await collectSessionEvent(input.directory, event);
|
|
574
|
+
|
|
575
|
+
const syncResult = await syncToRemote(input.directory, input.sessionId);
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
collected: true,
|
|
579
|
+
synced: syncResult.success && (syncResult.filesCommitted ?? 0) > 0,
|
|
580
|
+
};
|
|
581
|
+
} catch (e) {
|
|
582
|
+
return {
|
|
583
|
+
collected: false,
|
|
584
|
+
synced: false,
|
|
585
|
+
error: e instanceof Error ? e.message : String(e),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
// 3-second timeout (same pattern as session-reflector.ts)
|
|
591
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
592
|
+
const timeout = new Promise<NexusSessionEndResult>(resolve => {
|
|
593
|
+
timeoutHandle = setTimeout(
|
|
594
|
+
() => resolve({ collected: false, synced: false, error: 'timeout' }),
|
|
595
|
+
3000
|
|
596
|
+
);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
return await Promise.race([work(), timeout]);
|
|
601
|
+
} finally {
|
|
602
|
+
clearTimeout(timeoutHandle);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
**Step 4: 运行测试确认通过**
|
|
608
|
+
|
|
609
|
+
```bash
|
|
610
|
+
npm test -- src/hooks/nexus/__tests__/session-end-hook.test.ts
|
|
611
|
+
```
|
|
612
|
+
Expected: PASS (2 tests)
|
|
613
|
+
|
|
614
|
+
**Step 5: Commit**
|
|
615
|
+
|
|
616
|
+
```bash
|
|
617
|
+
git add src/hooks/nexus/session-end-hook.ts src/hooks/nexus/__tests__/session-end-hook.test.ts
|
|
618
|
+
git commit -m "feat(nexus): add session-end hook integration"
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
### Task 5: 注册 nexus hook 到 processSessionEnd
|
|
624
|
+
|
|
625
|
+
**Files:**
|
|
626
|
+
- Modify: `src/hooks/session-end/index.ts`(在 reflectOnSessionEnd 之后添加 nexus 调用)
|
|
627
|
+
|
|
628
|
+
**Step 1: 写失败测试**
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
// src/hooks/session-end/__tests__/nexus-integration.test.ts
|
|
632
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
633
|
+
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
634
|
+
import { join } from 'path';
|
|
635
|
+
import { tmpdir } from 'os';
|
|
636
|
+
|
|
637
|
+
describe('processSessionEnd nexus integration', () => {
|
|
638
|
+
let tmpDir: string;
|
|
639
|
+
|
|
640
|
+
beforeEach(() => {
|
|
641
|
+
tmpDir = join(tmpdir(), `nexus-se-int-${Date.now()}`);
|
|
642
|
+
mkdirSync(join(tmpDir, '.omc', 'nexus'), { recursive: true });
|
|
643
|
+
writeFileSync(
|
|
644
|
+
join(tmpDir, '.omc', 'nexus', 'config.json'),
|
|
645
|
+
JSON.stringify({ enabled: true, gitRemote: '' })
|
|
646
|
+
);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
afterEach(() => {
|
|
650
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('processSessionEnd completes without throwing when nexus enabled', async () => {
|
|
654
|
+
const { processSessionEnd } = await import('../index.js');
|
|
655
|
+
const result = await processSessionEnd({
|
|
656
|
+
session_id: 'test-nexus-int-001',
|
|
657
|
+
transcript_path: '',
|
|
658
|
+
cwd: tmpDir,
|
|
659
|
+
permission_mode: 'default',
|
|
660
|
+
hook_event_name: 'SessionEnd',
|
|
661
|
+
reason: 'other',
|
|
662
|
+
});
|
|
663
|
+
expect(result.continue).toBe(true);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
**Step 2: 运行测试确认失败**
|
|
669
|
+
|
|
670
|
+
```bash
|
|
671
|
+
npm test -- src/hooks/session-end/__tests__/nexus-integration.test.ts
|
|
672
|
+
```
|
|
673
|
+
Expected: FAIL — 因为 `../nexus/session-end-hook.js` 模块尚不存在,动态 import 会抛出 MODULE_NOT_FOUND 错误,导致测试失败。Step 3 添加该模块后测试才会通过。
|
|
674
|
+
|
|
675
|
+
**Step 3: 修改 processSessionEnd**
|
|
676
|
+
|
|
677
|
+
在 `src/hooks/session-end/index.ts` 的 `reflectOnSessionEnd` 调用块之后添加:
|
|
678
|
+
|
|
679
|
+
```typescript
|
|
680
|
+
// Nexus consciousness sync: collect session data and push to nexus-daemon (best-effort)
|
|
681
|
+
try {
|
|
682
|
+
const { handleNexusSessionEnd } = await import('../nexus/session-end-hook.js');
|
|
683
|
+
await handleNexusSessionEnd({
|
|
684
|
+
sessionId: input.session_id,
|
|
685
|
+
directory: resolveToWorktreeRoot(input.cwd),
|
|
686
|
+
durationMs: metrics.duration_ms,
|
|
687
|
+
agentsSpawned: metrics.agents_spawned,
|
|
688
|
+
agentsCompleted: metrics.agents_completed,
|
|
689
|
+
modesUsed: metrics.modes_used,
|
|
690
|
+
});
|
|
691
|
+
} catch {
|
|
692
|
+
// Nexus failures must never block session end
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
**Step 4: 运行测试确认通过**
|
|
697
|
+
|
|
698
|
+
```bash
|
|
699
|
+
npm test -- src/hooks/session-end/__tests__/nexus-integration.test.ts
|
|
700
|
+
npm test -- src/hooks/nexus/
|
|
701
|
+
```
|
|
702
|
+
Expected: PASS
|
|
703
|
+
|
|
704
|
+
**Step 5: Commit**
|
|
705
|
+
|
|
706
|
+
```bash
|
|
707
|
+
git add src/hooks/session-end/index.ts src/hooks/session-end/__tests__/nexus-integration.test.ts
|
|
708
|
+
git commit -m "feat(nexus): register nexus hook in processSessionEnd"
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
### Task 6: nexus-daemon 基础框架(Python)
|
|
715
|
+
|
|
716
|
+
**Files:**
|
|
717
|
+
- Create: `nexus-daemon/daemon.py`
|
|
718
|
+
- Create: `nexus-daemon/requirements.txt`
|
|
719
|
+
- Create: `nexus-daemon/README.md`
|
|
720
|
+
- Create: `nexus-daemon/tests/test_daemon.py`
|
|
721
|
+
|
|
722
|
+
**Step 1: 写失败测试**
|
|
723
|
+
|
|
724
|
+
```python
|
|
725
|
+
# nexus-daemon/tests/test_daemon.py
|
|
726
|
+
import pytest
|
|
727
|
+
import json
|
|
728
|
+
import os
|
|
729
|
+
from pathlib import Path
|
|
730
|
+
from unittest.mock import patch, MagicMock
|
|
731
|
+
|
|
732
|
+
# Add parent to path
|
|
733
|
+
import sys
|
|
734
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
735
|
+
|
|
736
|
+
from daemon import NexusDaemon, DaemonConfig, load_config
|
|
737
|
+
|
|
738
|
+
class TestLoadConfig:
|
|
739
|
+
def test_returns_default_when_no_file(self, tmp_path):
|
|
740
|
+
config = load_config(tmp_path / 'nonexistent.json')
|
|
741
|
+
assert config.poll_interval == 60
|
|
742
|
+
assert config.openrouter_api_key == ''
|
|
743
|
+
|
|
744
|
+
def test_loads_from_file(self, tmp_path):
|
|
745
|
+
config_file = tmp_path / 'config.json'
|
|
746
|
+
config_file.write_text(json.dumps({
|
|
747
|
+
'poll_interval': 30,
|
|
748
|
+
'openrouter_api_key': 'sk-test-123'
|
|
749
|
+
}))
|
|
750
|
+
config = load_config(config_file)
|
|
751
|
+
assert config.poll_interval == 30
|
|
752
|
+
assert config.openrouter_api_key == 'sk-test-123'
|
|
753
|
+
|
|
754
|
+
class TestNexusDaemon:
|
|
755
|
+
def test_init_creates_directories(self, tmp_path):
|
|
756
|
+
daemon = NexusDaemon(repo_path=tmp_path)
|
|
757
|
+
assert (tmp_path / 'events').exists()
|
|
758
|
+
assert (tmp_path / 'improvements').exists()
|
|
759
|
+
assert (tmp_path / 'consciousness').exists()
|
|
760
|
+
assert (tmp_path / 'evolution').exists()
|
|
761
|
+
|
|
762
|
+
def test_get_new_events_returns_empty_when_no_files(self, tmp_path):
|
|
763
|
+
daemon = NexusDaemon(repo_path=tmp_path)
|
|
764
|
+
events = daemon.get_new_events()
|
|
765
|
+
assert events == []
|
|
766
|
+
|
|
767
|
+
def test_get_new_events_returns_unprocessed_files(self, tmp_path):
|
|
768
|
+
daemon = NexusDaemon(repo_path=tmp_path)
|
|
769
|
+
event_file = tmp_path / 'events' / 'sess-001-1234567890.json'
|
|
770
|
+
event_file.write_text(json.dumps({
|
|
771
|
+
'sessionId': 'sess-001',
|
|
772
|
+
'timestamp': '2026-02-26T14:00:00Z',
|
|
773
|
+
'toolCalls': [],
|
|
774
|
+
'agentsSpawned': 2,
|
|
775
|
+
}))
|
|
776
|
+
events = daemon.get_new_events()
|
|
777
|
+
assert len(events) == 1
|
|
778
|
+
assert events[0]['sessionId'] == 'sess-001'
|
|
779
|
+
|
|
780
|
+
def test_mark_event_processed(self, tmp_path):
|
|
781
|
+
daemon = NexusDaemon(repo_path=tmp_path)
|
|
782
|
+
event_file = tmp_path / 'events' / 'sess-002-9999.json'
|
|
783
|
+
event_file.write_text('{}')
|
|
784
|
+
daemon.mark_event_processed('sess-002-9999.json')
|
|
785
|
+
# Second call should not return this file
|
|
786
|
+
events = daemon.get_new_events()
|
|
787
|
+
assert all(e.get('_filename') != 'sess-002-9999.json' for e in events)
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
**Step 2: 运行测试确认失败**
|
|
791
|
+
|
|
792
|
+
```bash
|
|
793
|
+
cd nexus-daemon && pip install pytest && python -m pytest tests/test_daemon.py -v
|
|
794
|
+
```
|
|
795
|
+
Expected: FAIL — `ModuleNotFoundError: No module named 'daemon'`
|
|
796
|
+
|
|
797
|
+
**Step 3: 实现 daemon.py**
|
|
798
|
+
|
|
799
|
+
```python
|
|
800
|
+
# nexus-daemon/daemon.py
|
|
801
|
+
"""
|
|
802
|
+
nexus-daemon: VPS 守护进程
|
|
803
|
+
每分钟 git pull 拉取新事件,运行进化引擎,生成改进建议推回仓库。
|
|
804
|
+
"""
|
|
805
|
+
from __future__ import annotations
|
|
806
|
+
|
|
807
|
+
import asyncio
|
|
808
|
+
import json
|
|
809
|
+
import logging
|
|
810
|
+
import os
|
|
811
|
+
import subprocess
|
|
812
|
+
import sys
|
|
813
|
+
from dataclasses import dataclass, field
|
|
814
|
+
from pathlib import Path
|
|
815
|
+
from typing import Any
|
|
816
|
+
|
|
817
|
+
logging.basicConfig(
|
|
818
|
+
level=logging.INFO,
|
|
819
|
+
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
|
|
820
|
+
)
|
|
821
|
+
logger = logging.getLogger('nexus-daemon')
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
@dataclass
|
|
825
|
+
class DaemonConfig:
|
|
826
|
+
poll_interval: int = 60 # seconds between git pull cycles
|
|
827
|
+
openrouter_api_key: str = ''
|
|
828
|
+
openrouter_model: str = 'anthropic/claude-3-5-haiku'
|
|
829
|
+
telegram_token: str = ''
|
|
830
|
+
telegram_chat_id: str = ''
|
|
831
|
+
consciousness_interval: int = 300 # seconds between consciousness loops
|
|
832
|
+
consciousness_budget_percent: int = 10
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def load_config(config_path: Path) -> DaemonConfig:
|
|
836
|
+
"""Load config from JSON file, return defaults on missing/error."""
|
|
837
|
+
try:
|
|
838
|
+
if not config_path.exists():
|
|
839
|
+
return DaemonConfig()
|
|
840
|
+
raw = json.loads(config_path.read_text())
|
|
841
|
+
return DaemonConfig(
|
|
842
|
+
poll_interval=raw.get('poll_interval', 60),
|
|
843
|
+
openrouter_api_key=raw.get('openrouter_api_key', ''),
|
|
844
|
+
openrouter_model=raw.get('openrouter_model', 'anthropic/claude-3-5-haiku'),
|
|
845
|
+
telegram_token=raw.get('telegram_token', ''),
|
|
846
|
+
telegram_chat_id=raw.get('telegram_chat_id', ''),
|
|
847
|
+
consciousness_interval=raw.get('consciousness_interval', 300),
|
|
848
|
+
consciousness_budget_percent=raw.get('consciousness_budget_percent', 10),
|
|
849
|
+
)
|
|
850
|
+
except Exception:
|
|
851
|
+
return DaemonConfig()
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
class NexusDaemon:
|
|
855
|
+
"""Core daemon: git pull loop + event processing."""
|
|
856
|
+
|
|
857
|
+
PROCESSED_LOG = '.processed_events.json'
|
|
858
|
+
|
|
859
|
+
def __init__(self, repo_path: Path, config: DaemonConfig | None = None):
|
|
860
|
+
self.repo_path = Path(repo_path)
|
|
861
|
+
self.config = config or DaemonConfig()
|
|
862
|
+
self._processed: set[str] = set()
|
|
863
|
+
self._ensure_dirs()
|
|
864
|
+
self._load_processed_log()
|
|
865
|
+
|
|
866
|
+
def _ensure_dirs(self) -> None:
|
|
867
|
+
for d in ['events', 'improvements', 'consciousness', 'evolution']:
|
|
868
|
+
(self.repo_path / d).mkdir(parents=True, exist_ok=True)
|
|
869
|
+
|
|
870
|
+
def _processed_log_path(self) -> Path:
|
|
871
|
+
return self.repo_path / self.PROCESSED_LOG
|
|
872
|
+
|
|
873
|
+
def _load_processed_log(self) -> None:
|
|
874
|
+
try:
|
|
875
|
+
path = self._processed_log_path()
|
|
876
|
+
if path.exists():
|
|
877
|
+
self._processed = set(json.loads(path.read_text()))
|
|
878
|
+
except Exception:
|
|
879
|
+
self._processed = set()
|
|
880
|
+
|
|
881
|
+
def _save_processed_log(self) -> None:
|
|
882
|
+
try:
|
|
883
|
+
self._processed_log_path().write_text(
|
|
884
|
+
json.dumps(sorted(self._processed), indent=2)
|
|
885
|
+
)
|
|
886
|
+
except Exception:
|
|
887
|
+
pass
|
|
888
|
+
|
|
889
|
+
def git_pull(self) -> bool:
|
|
890
|
+
"""Pull latest from remote. Returns True on success."""
|
|
891
|
+
try:
|
|
892
|
+
result = subprocess.run(
|
|
893
|
+
['git', 'fetch', 'origin'],
|
|
894
|
+
cwd=self.repo_path,
|
|
895
|
+
capture_output=True,
|
|
896
|
+
text=True,
|
|
897
|
+
timeout=30,
|
|
898
|
+
)
|
|
899
|
+
if result.returncode != 0:
|
|
900
|
+
logger.warning('git fetch failed: %s', result.stderr.strip())
|
|
901
|
+
return False
|
|
902
|
+
result = subprocess.run(
|
|
903
|
+
['git', 'rebase', 'origin/main'],
|
|
904
|
+
cwd=self.repo_path,
|
|
905
|
+
capture_output=True,
|
|
906
|
+
text=True,
|
|
907
|
+
timeout=30,
|
|
908
|
+
)
|
|
909
|
+
if result.returncode == 0:
|
|
910
|
+
logger.info('git pull: success')
|
|
911
|
+
return True
|
|
912
|
+
logger.warning('git pull failed: %s', result.stderr.strip())
|
|
913
|
+
return False
|
|
914
|
+
except Exception as e:
|
|
915
|
+
logger.error('git pull error: %s', e)
|
|
916
|
+
return False
|
|
917
|
+
|
|
918
|
+
def git_push(self, message: str) -> bool:
|
|
919
|
+
"""Commit and push improvements. Returns True on success."""
|
|
920
|
+
try:
|
|
921
|
+
subprocess.run(['git', 'add', 'improvements/', 'evolution/'], cwd=self.repo_path,
|
|
922
|
+
capture_output=True, timeout=10)
|
|
923
|
+
result = subprocess.run(
|
|
924
|
+
['git', 'commit', '-m', message],
|
|
925
|
+
cwd=self.repo_path, capture_output=True, text=True, timeout=10,
|
|
926
|
+
)
|
|
927
|
+
if result.returncode != 0:
|
|
928
|
+
return False # nothing to commit
|
|
929
|
+
subprocess.run(['git', 'push', 'origin', 'HEAD'],
|
|
930
|
+
cwd=self.repo_path, capture_output=True, timeout=30)
|
|
931
|
+
return True
|
|
932
|
+
except Exception as e:
|
|
933
|
+
logger.error('git push error: %s', e)
|
|
934
|
+
return False
|
|
935
|
+
|
|
936
|
+
def get_new_events(self) -> list[dict[str, Any]]:
|
|
937
|
+
"""Return unprocessed event files from events/ directory."""
|
|
938
|
+
events_dir = self.repo_path / 'events'
|
|
939
|
+
result = []
|
|
940
|
+
for f in sorted(events_dir.glob('*.json')):
|
|
941
|
+
if f.name in self._processed:
|
|
942
|
+
continue
|
|
943
|
+
try:
|
|
944
|
+
data = json.loads(f.read_text())
|
|
945
|
+
data['_filename'] = f.name
|
|
946
|
+
result.append(data)
|
|
947
|
+
except Exception:
|
|
948
|
+
pass
|
|
949
|
+
return result
|
|
950
|
+
|
|
951
|
+
def mark_event_processed(self, filename: str) -> None:
|
|
952
|
+
self._processed.add(filename)
|
|
953
|
+
self._save_processed_log()
|
|
954
|
+
|
|
955
|
+
async def run_once(self) -> None:
|
|
956
|
+
"""Single poll cycle: pull → process events → push."""
|
|
957
|
+
self.git_pull()
|
|
958
|
+
events = self.get_new_events()
|
|
959
|
+
if not events:
|
|
960
|
+
return
|
|
961
|
+
logger.info('Processing %d new event(s)', len(events))
|
|
962
|
+
for event in events:
|
|
963
|
+
try:
|
|
964
|
+
await self._process_event(event)
|
|
965
|
+
self.mark_event_processed(event['_filename'])
|
|
966
|
+
except Exception as e:
|
|
967
|
+
logger.error('Error processing event %s: %s', event.get('_filename'), e)
|
|
968
|
+
|
|
969
|
+
async def _process_event(self, event: dict[str, Any]) -> None:
|
|
970
|
+
"""Placeholder: route to evolution engine."""
|
|
971
|
+
logger.info('Event received: session=%s agents=%s',
|
|
972
|
+
event.get('sessionId', '?'),
|
|
973
|
+
event.get('agentsSpawned', 0))
|
|
974
|
+
|
|
975
|
+
async def run(self) -> None:
|
|
976
|
+
"""Main loop: poll every config.poll_interval seconds."""
|
|
977
|
+
logger.info('nexus-daemon started (poll_interval=%ds)', self.config.poll_interval)
|
|
978
|
+
while True:
|
|
979
|
+
try:
|
|
980
|
+
await self.run_once()
|
|
981
|
+
except Exception as e:
|
|
982
|
+
logger.error('run_once error: %s', e)
|
|
983
|
+
await asyncio.sleep(self.config.poll_interval)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def main() -> None:
|
|
987
|
+
repo_path = Path(os.environ.get('NEXUS_REPO_PATH', '.'))
|
|
988
|
+
config_path = repo_path / 'config.json'
|
|
989
|
+
config = load_config(config_path)
|
|
990
|
+
daemon = NexusDaemon(repo_path=repo_path, config=config)
|
|
991
|
+
asyncio.run(daemon.run())
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
if __name__ == '__main__':
|
|
995
|
+
main()
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
**Step 4: 创建 requirements.txt**
|
|
999
|
+
|
|
1000
|
+
```
|
|
1001
|
+
# nexus-daemon/requirements.txt
|
|
1002
|
+
aiohttp>=3.9.0
|
|
1003
|
+
pytest>=7.0.0
|
|
1004
|
+
pytest-asyncio>=0.23.0
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
**Step 5: 运行测试确认通过**
|
|
1008
|
+
|
|
1009
|
+
```bash
|
|
1010
|
+
cd nexus-daemon && python -m pytest tests/test_daemon.py -v
|
|
1011
|
+
```
|
|
1012
|
+
Expected: PASS (6 tests)
|
|
1013
|
+
|
|
1014
|
+
**Step 6: Commit**
|
|
1015
|
+
|
|
1016
|
+
```bash
|
|
1017
|
+
git add nexus-daemon/
|
|
1018
|
+
git commit -m "feat(nexus): add nexus-daemon Python base framework"
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
---
|
|
1022
|
+
|
|
1023
|
+
## P1:进化引擎与改进拉取
|
|
1024
|
+
|
|
1025
|
+
### Task 7: Evolution Engine MVP(模式检测 + knowledge_base 写入)
|
|
1026
|
+
|
|
1027
|
+
**Files:**
|
|
1028
|
+
- Create: `nexus-daemon/evolution_engine.py`
|
|
1029
|
+
- Create: `nexus-daemon/tests/test_evolution_engine.py`
|
|
1030
|
+
- Modify: `nexus-daemon/daemon.py`
|
|
1031
|
+
|
|
1032
|
+
**Step 1: 写失败测试**
|
|
1033
|
+
|
|
1034
|
+
```python
|
|
1035
|
+
# nexus-daemon/tests/test_evolution_engine.py
|
|
1036
|
+
import pytest
|
|
1037
|
+
import json
|
|
1038
|
+
from pathlib import Path
|
|
1039
|
+
import sys
|
|
1040
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
1041
|
+
|
|
1042
|
+
from evolution_engine import EvolutionEngine, PatternRecord, detect_patterns
|
|
1043
|
+
|
|
1044
|
+
class TestDetectPatterns:
|
|
1045
|
+
def test_returns_empty_for_no_events(self):
|
|
1046
|
+
patterns = detect_patterns([])
|
|
1047
|
+
assert patterns == []
|
|
1048
|
+
|
|
1049
|
+
def test_detects_repeated_modes(self):
|
|
1050
|
+
events = [
|
|
1051
|
+
{'modesUsed': ['ultrawork'], 'agentsSpawned': 3},
|
|
1052
|
+
{'modesUsed': ['ultrawork'], 'agentsSpawned': 2},
|
|
1053
|
+
{'modesUsed': ['ultrawork'], 'agentsSpawned': 4},
|
|
1054
|
+
]
|
|
1055
|
+
patterns = detect_patterns(events)
|
|
1056
|
+
mode_patterns = [p for p in patterns if p.pattern_type == 'mode_usage']
|
|
1057
|
+
assert any(p.value == 'ultrawork' and p.occurrences >= 3 for p in mode_patterns)
|
|
1058
|
+
|
|
1059
|
+
def test_pattern_below_threshold_not_promoted(self):
|
|
1060
|
+
events = [
|
|
1061
|
+
{'modesUsed': ['ralph'], 'agentsSpawned': 1},
|
|
1062
|
+
{'modesUsed': ['ralph'], 'agentsSpawned': 1},
|
|
1063
|
+
]
|
|
1064
|
+
patterns = detect_patterns(events)
|
|
1065
|
+
promoted = [p for p in patterns if p.occurrences >= 3]
|
|
1066
|
+
assert len(promoted) == 0
|
|
1067
|
+
|
|
1068
|
+
class TestEvolutionEngine:
|
|
1069
|
+
def test_init_creates_knowledge_base(self, tmp_path):
|
|
1070
|
+
engine = EvolutionEngine(repo_path=tmp_path)
|
|
1071
|
+
assert (tmp_path / 'evolution' / 'knowledge_base.md').exists()
|
|
1072
|
+
|
|
1073
|
+
def test_process_events_updates_knowledge_base(self, tmp_path):
|
|
1074
|
+
engine = EvolutionEngine(repo_path=tmp_path)
|
|
1075
|
+
events = [
|
|
1076
|
+
{'sessionId': f'sess-{i}', 'modesUsed': ['ultrawork'],
|
|
1077
|
+
'agentsSpawned': 3, 'agentsCompleted': 3, 'toolCalls': []}
|
|
1078
|
+
for i in range(3)
|
|
1079
|
+
]
|
|
1080
|
+
engine.process_events(events)
|
|
1081
|
+
kb = (tmp_path / 'evolution' / 'knowledge_base.md').read_text()
|
|
1082
|
+
assert 'ultrawork' in kb
|
|
1083
|
+
|
|
1084
|
+
def test_process_events_writes_pattern_library(self, tmp_path):
|
|
1085
|
+
engine = EvolutionEngine(repo_path=tmp_path)
|
|
1086
|
+
events = [
|
|
1087
|
+
{'sessionId': f's{i}', 'modesUsed': ['ralph'],
|
|
1088
|
+
'agentsSpawned': 2, 'agentsCompleted': 2, 'toolCalls': []}
|
|
1089
|
+
for i in range(3)
|
|
1090
|
+
]
|
|
1091
|
+
engine.process_events(events)
|
|
1092
|
+
pl = (tmp_path / 'evolution' / 'pattern_library.md').read_text()
|
|
1093
|
+
assert 'ralph' in pl
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
**Step 2: 运行测试确认失败**
|
|
1097
|
+
|
|
1098
|
+
```bash
|
|
1099
|
+
cd nexus-daemon && python -m pytest tests/test_evolution_engine.py -v
|
|
1100
|
+
```
|
|
1101
|
+
Expected: FAIL — `ModuleNotFoundError: No module named 'evolution_engine'`
|
|
1102
|
+
|
|
1103
|
+
**Step 3: 实现 evolution_engine.py**
|
|
1104
|
+
|
|
1105
|
+
```python
|
|
1106
|
+
# nexus-daemon/evolution_engine.py
|
|
1107
|
+
from __future__ import annotations
|
|
1108
|
+
import logging
|
|
1109
|
+
from collections import Counter
|
|
1110
|
+
from dataclasses import dataclass, field
|
|
1111
|
+
from datetime import datetime, timezone
|
|
1112
|
+
from pathlib import Path
|
|
1113
|
+
from typing import Any
|
|
1114
|
+
|
|
1115
|
+
logger = logging.getLogger('nexus.evolution')
|
|
1116
|
+
PATTERN_THRESHOLD = 3
|
|
1117
|
+
|
|
1118
|
+
@dataclass
|
|
1119
|
+
class PatternRecord:
|
|
1120
|
+
pattern_type: str
|
|
1121
|
+
value: str
|
|
1122
|
+
occurrences: int
|
|
1123
|
+
confidence: int
|
|
1124
|
+
first_seen: str
|
|
1125
|
+
last_seen: str
|
|
1126
|
+
evidence: list[str] = field(default_factory=list)
|
|
1127
|
+
|
|
1128
|
+
def detect_patterns(events: list[dict[str, Any]]) -> list[PatternRecord]:
|
|
1129
|
+
if not events:
|
|
1130
|
+
return []
|
|
1131
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
1132
|
+
patterns: list[PatternRecord] = []
|
|
1133
|
+
mode_counter: Counter[str] = Counter()
|
|
1134
|
+
for event in events:
|
|
1135
|
+
for mode in event.get('modesUsed', []):
|
|
1136
|
+
mode_counter[mode] += 1
|
|
1137
|
+
for mode, count in mode_counter.items():
|
|
1138
|
+
confidence = min(100, 50 + count * 10)
|
|
1139
|
+
patterns.append(PatternRecord(
|
|
1140
|
+
pattern_type='mode_usage', value=mode, occurrences=count,
|
|
1141
|
+
confidence=confidence, first_seen=now, last_seen=now,
|
|
1142
|
+
evidence=[f'Appeared in {count} sessions'],
|
|
1143
|
+
))
|
|
1144
|
+
return patterns
|
|
1145
|
+
|
|
1146
|
+
class EvolutionEngine:
|
|
1147
|
+
KNOWLEDGE_BASE = 'evolution/knowledge_base.md'
|
|
1148
|
+
PATTERN_LIBRARY = 'evolution/pattern_library.md'
|
|
1149
|
+
|
|
1150
|
+
def __init__(self, repo_path: Path):
|
|
1151
|
+
self.repo_path = Path(repo_path)
|
|
1152
|
+
self._ensure_files()
|
|
1153
|
+
|
|
1154
|
+
def _ensure_files(self) -> None:
|
|
1155
|
+
evo_dir = self.repo_path / 'evolution'
|
|
1156
|
+
evo_dir.mkdir(parents=True, exist_ok=True)
|
|
1157
|
+
kb = self.repo_path / self.KNOWLEDGE_BASE
|
|
1158
|
+
if not kb.exists():
|
|
1159
|
+
kb.write_text('# Knowledge Base\n\n_Auto-generated by nexus_\n\n')
|
|
1160
|
+
pl = self.repo_path / self.PATTERN_LIBRARY
|
|
1161
|
+
if not pl.exists():
|
|
1162
|
+
pl.write_text('# Pattern Library\n\n_Patterns promoted after >= 3 occurrences_\n\n')
|
|
1163
|
+
|
|
1164
|
+
def process_events(self, events: list[dict[str, Any]]) -> list[PatternRecord]:
|
|
1165
|
+
if not events:
|
|
1166
|
+
return []
|
|
1167
|
+
patterns = detect_patterns(events)
|
|
1168
|
+
promoted = [p for p in patterns if p.occurrences >= PATTERN_THRESHOLD]
|
|
1169
|
+
if promoted:
|
|
1170
|
+
self._update_knowledge_base(promoted, events)
|
|
1171
|
+
self._update_pattern_library(promoted)
|
|
1172
|
+
logger.info('Promoted %d pattern(s)', len(promoted))
|
|
1173
|
+
return promoted
|
|
1174
|
+
|
|
1175
|
+
def _update_knowledge_base(self, patterns: list[PatternRecord], events: list[dict]) -> None:
|
|
1176
|
+
kb_path = self.repo_path / self.KNOWLEDGE_BASE
|
|
1177
|
+
now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
1178
|
+
lines = [f'\n## Update: {now}\n\nProcessed {len(events)} session(s):\n\n']
|
|
1179
|
+
for p in patterns:
|
|
1180
|
+
lines.append(f'- **{p.pattern_type}** `{p.value}`: {p.occurrences} occurrences, confidence={p.confidence}\n')
|
|
1181
|
+
with open(kb_path, 'a', encoding='utf-8') as f:
|
|
1182
|
+
f.writelines(lines)
|
|
1183
|
+
|
|
1184
|
+
def _update_pattern_library(self, patterns: list[PatternRecord]) -> None:
|
|
1185
|
+
pl_path = self.repo_path / self.PATTERN_LIBRARY
|
|
1186
|
+
now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
1187
|
+
lines = [f'\n## {now}\n\n']
|
|
1188
|
+
for p in patterns:
|
|
1189
|
+
lines.append(
|
|
1190
|
+
f'### {p.pattern_type}: {p.value}\n'
|
|
1191
|
+
f'- Occurrences: {p.occurrences}\n'
|
|
1192
|
+
f'- Confidence: {p.confidence}\n\n'
|
|
1193
|
+
)
|
|
1194
|
+
with open(pl_path, 'a', encoding='utf-8') as f:
|
|
1195
|
+
f.writelines(lines)
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
**Step 4: 集成到 daemon.py**
|
|
1199
|
+
|
|
1200
|
+
在 `NexusDaemon.__init__` 末尾添加:
|
|
1201
|
+
|
|
1202
|
+
```python
|
|
1203
|
+
from evolution_engine import EvolutionEngine
|
|
1204
|
+
self._evolution = EvolutionEngine(repo_path=self.repo_path)
|
|
1205
|
+
self._pending_events: list[dict] = []
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
将 `_process_event` 替换为:
|
|
1209
|
+
|
|
1210
|
+
```python
|
|
1211
|
+
async def _process_event(self, event: dict[str, Any]) -> None:
|
|
1212
|
+
logger.info('Event: session=%s agents=%s', event.get('sessionId','?'), event.get('agentsSpawned',0))
|
|
1213
|
+
self._pending_events.append(event)
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
在 `run_once` 的事件循环结束后追加:
|
|
1217
|
+
|
|
1218
|
+
```python
|
|
1219
|
+
if self._pending_events:
|
|
1220
|
+
promoted = self._evolution.process_events(self._pending_events)
|
|
1221
|
+
if promoted:
|
|
1222
|
+
self.git_push(f'nexus: evolution promoted {len(promoted)} pattern(s)')
|
|
1223
|
+
self._pending_events = []
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
**Step 5: 运行测试确认通过**
|
|
1227
|
+
|
|
1228
|
+
```bash
|
|
1229
|
+
cd nexus-daemon && python -m pytest tests/ -v
|
|
1230
|
+
```
|
|
1231
|
+
Expected: PASS (10 tests)
|
|
1232
|
+
|
|
1233
|
+
**Step 6: Commit**
|
|
1234
|
+
|
|
1235
|
+
```bash
|
|
1236
|
+
git add nexus-daemon/evolution_engine.py nexus-daemon/tests/test_evolution_engine.py nexus-daemon/daemon.py
|
|
1237
|
+
git commit -m "feat(nexus): add Evolution Engine MVP with pattern detection"
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
---
|
|
1241
|
+
|
|
1242
|
+
---
|
|
1243
|
+
|
|
1244
|
+
### Task 8: improvement-puller hook(拉取并应用改进建议)
|
|
1245
|
+
|
|
1246
|
+
**Files:**
|
|
1247
|
+
- Create: `src/hooks/nexus/improvement-puller.ts`
|
|
1248
|
+
- Create: `src/hooks/nexus/__tests__/improvement-puller.test.ts`
|
|
1249
|
+
|
|
1250
|
+
**Step 1: 写失败测试**
|
|
1251
|
+
|
|
1252
|
+
```typescript
|
|
1253
|
+
// src/hooks/nexus/__tests__/improvement-puller.test.ts
|
|
1254
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1255
|
+
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
1256
|
+
import { join } from 'path';
|
|
1257
|
+
import { tmpdir } from 'os';
|
|
1258
|
+
import { getImprovementsDir, loadPendingImprovements } from '../improvement-puller.js';
|
|
1259
|
+
import type { ImprovementSuggestion } from '../types.js';
|
|
1260
|
+
|
|
1261
|
+
describe('loadPendingImprovements', () => {
|
|
1262
|
+
let tmpDir: string;
|
|
1263
|
+
|
|
1264
|
+
beforeEach(() => {
|
|
1265
|
+
tmpDir = join(tmpdir(), `nexus-ip-test-${Date.now()}`);
|
|
1266
|
+
mkdirSync(join(tmpDir, '.omc', 'nexus', 'improvements'), { recursive: true });
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
afterEach(() => {
|
|
1270
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it('returns empty array when no improvement files', () => {
|
|
1274
|
+
const improvements = loadPendingImprovements(tmpDir);
|
|
1275
|
+
expect(improvements).toEqual([]);
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it('loads pending improvement files', () => {
|
|
1279
|
+
const imp: ImprovementSuggestion = {
|
|
1280
|
+
id: 'imp-001',
|
|
1281
|
+
createdAt: '2026-02-26T14:00:00Z',
|
|
1282
|
+
source: 'evolution_engine',
|
|
1283
|
+
type: 'skill_update',
|
|
1284
|
+
targetFile: 'skills/learner/SKILL.md',
|
|
1285
|
+
confidence: 85,
|
|
1286
|
+
diff: '--- a/skills/learner/SKILL.md\n+++ b/skills/learner/SKILL.md\n@@ -1 +1 @@\n-old\n+new',
|
|
1287
|
+
reason: 'test reason',
|
|
1288
|
+
evidence: {},
|
|
1289
|
+
status: 'pending',
|
|
1290
|
+
testResult: null,
|
|
1291
|
+
};
|
|
1292
|
+
writeFileSync(
|
|
1293
|
+
join(tmpDir, '.omc', 'nexus', 'improvements', 'imp-001.json'),
|
|
1294
|
+
JSON.stringify(imp)
|
|
1295
|
+
);
|
|
1296
|
+
const improvements = loadPendingImprovements(tmpDir);
|
|
1297
|
+
expect(improvements.length).toBe(1);
|
|
1298
|
+
expect(improvements[0].id).toBe('imp-001');
|
|
1299
|
+
expect(improvements[0].confidence).toBe(85);
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it('skips non-pending improvements', () => {
|
|
1303
|
+
const imp: ImprovementSuggestion = {
|
|
1304
|
+
id: 'imp-002',
|
|
1305
|
+
createdAt: '2026-02-26T14:00:00Z',
|
|
1306
|
+
source: 'evolution_engine',
|
|
1307
|
+
type: 'skill_update',
|
|
1308
|
+
targetFile: 'skills/test/SKILL.md',
|
|
1309
|
+
confidence: 90,
|
|
1310
|
+
diff: '',
|
|
1311
|
+
reason: 'already applied',
|
|
1312
|
+
evidence: {},
|
|
1313
|
+
status: 'applied',
|
|
1314
|
+
testResult: 'PASS',
|
|
1315
|
+
};
|
|
1316
|
+
writeFileSync(
|
|
1317
|
+
join(tmpDir, '.omc', 'nexus', 'improvements', 'imp-002.json'),
|
|
1318
|
+
JSON.stringify(imp)
|
|
1319
|
+
);
|
|
1320
|
+
const improvements = loadPendingImprovements(tmpDir);
|
|
1321
|
+
expect(improvements).toEqual([]);
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
**Step 2: 运行测试确认失败**
|
|
1327
|
+
|
|
1328
|
+
```bash
|
|
1329
|
+
npm test -- src/hooks/nexus/__tests__/improvement-puller.test.ts
|
|
1330
|
+
```
|
|
1331
|
+
Expected: FAIL — `Cannot find module '../improvement-puller.js'`
|
|
1332
|
+
|
|
1333
|
+
**Step 3: 实现 improvement-puller.ts**
|
|
1334
|
+
|
|
1335
|
+
```typescript
|
|
1336
|
+
// src/hooks/nexus/improvement-puller.ts
|
|
1337
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
1338
|
+
import { join } from 'path';
|
|
1339
|
+
import { isNexusEnabled } from './config.js';
|
|
1340
|
+
import type { ImprovementSuggestion } from './types.js';
|
|
1341
|
+
|
|
1342
|
+
const IMPROVEMENTS_SUBDIR = '.omc/nexus/improvements';
|
|
1343
|
+
|
|
1344
|
+
export function getImprovementsDir(directory: string): string {
|
|
1345
|
+
return join(directory, IMPROVEMENTS_SUBDIR);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
export function loadPendingImprovements(directory: string): ImprovementSuggestion[] {
|
|
1349
|
+
const improvementsDir = getImprovementsDir(directory);
|
|
1350
|
+
if (!existsSync(improvementsDir)) {
|
|
1351
|
+
return [];
|
|
1352
|
+
}
|
|
1353
|
+
const results: ImprovementSuggestion[] = [];
|
|
1354
|
+
for (const file of readdirSync(improvementsDir)) {
|
|
1355
|
+
if (!file.endsWith('.json')) continue;
|
|
1356
|
+
try {
|
|
1357
|
+
const raw = readFileSync(join(improvementsDir, file), 'utf-8');
|
|
1358
|
+
const imp = JSON.parse(raw) as ImprovementSuggestion;
|
|
1359
|
+
if (imp.status === 'pending') {
|
|
1360
|
+
results.push(imp);
|
|
1361
|
+
}
|
|
1362
|
+
} catch {
|
|
1363
|
+
// Skip malformed files
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return results;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
export interface PullResult {
|
|
1370
|
+
found: number;
|
|
1371
|
+
autoApplied: number;
|
|
1372
|
+
pendingReview: number;
|
|
1373
|
+
errors: string[];
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
export async function pullImprovements(directory: string): Promise<PullResult> {
|
|
1377
|
+
const result: PullResult = { found: 0, autoApplied: 0, pendingReview: 0, errors: [] };
|
|
1378
|
+
try {
|
|
1379
|
+
if (!isNexusEnabled(directory)) return result;
|
|
1380
|
+
const improvements = loadPendingImprovements(directory);
|
|
1381
|
+
result.found = improvements.length;
|
|
1382
|
+
// Routing by confidence is handled by the caller (session-end-hook or PreToolUse)
|
|
1383
|
+
result.pendingReview = improvements.length;
|
|
1384
|
+
} catch (e) {
|
|
1385
|
+
result.errors.push(e instanceof Error ? e.message : String(e));
|
|
1386
|
+
}
|
|
1387
|
+
return result;
|
|
1388
|
+
}
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
**Step 4: 运行测试确认通过**
|
|
1392
|
+
|
|
1393
|
+
```bash
|
|
1394
|
+
npm test -- src/hooks/nexus/__tests__/improvement-puller.test.ts
|
|
1395
|
+
```
|
|
1396
|
+
Expected: PASS (3 tests)
|
|
1397
|
+
|
|
1398
|
+
**Step 5: Commit**
|
|
1399
|
+
|
|
1400
|
+
```bash
|
|
1401
|
+
git add src/hooks/nexus/improvement-puller.ts src/hooks/nexus/__tests__/improvement-puller.test.ts
|
|
1402
|
+
git commit -m "feat(nexus): add improvement-puller hook"
|
|
1403
|
+
```
|
|
1404
|
+
|
|
1405
|
+
---
|
|
1406
|
+
|
|
1407
|
+
### Task 9: Telegram Bot 通知(Python)
|
|
1408
|
+
|
|
1409
|
+
**Files:**
|
|
1410
|
+
- Create: `nexus-daemon/telegram_bot.py`
|
|
1411
|
+
- Create: `nexus-daemon/tests/test_telegram_bot.py`
|
|
1412
|
+
- Modify: `nexus-daemon/daemon.py`(集成 Telegram 通知)
|
|
1413
|
+
|
|
1414
|
+
**Step 1: 写失败测试**
|
|
1415
|
+
|
|
1416
|
+
```python
|
|
1417
|
+
# nexus-daemon/tests/test_telegram_bot.py
|
|
1418
|
+
import pytest
|
|
1419
|
+
from unittest.mock import patch, AsyncMock, MagicMock
|
|
1420
|
+
import sys
|
|
1421
|
+
from pathlib import Path
|
|
1422
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
1423
|
+
|
|
1424
|
+
from telegram_bot import TelegramBot, format_improvement_message
|
|
1425
|
+
|
|
1426
|
+
class TestFormatImprovementMessage:
|
|
1427
|
+
def test_includes_id_and_confidence(self):
|
|
1428
|
+
imp = {
|
|
1429
|
+
'id': 'imp-001',
|
|
1430
|
+
'confidence': 85,
|
|
1431
|
+
'type': 'skill_update',
|
|
1432
|
+
'targetFile': 'skills/learner/SKILL.md',
|
|
1433
|
+
'reason': 'test reason',
|
|
1434
|
+
}
|
|
1435
|
+
msg = format_improvement_message(imp)
|
|
1436
|
+
assert 'imp-001' in msg
|
|
1437
|
+
assert '85' in msg
|
|
1438
|
+
assert 'skill_update' in msg
|
|
1439
|
+
|
|
1440
|
+
def test_includes_auto_apply_label_when_high_confidence(self):
|
|
1441
|
+
imp = {
|
|
1442
|
+
'id': 'imp-002',
|
|
1443
|
+
'confidence': 90,
|
|
1444
|
+
'type': 'skill_update',
|
|
1445
|
+
'targetFile': 'skills/test/SKILL.md',
|
|
1446
|
+
'reason': 'high confidence',
|
|
1447
|
+
}
|
|
1448
|
+
msg = format_improvement_message(imp)
|
|
1449
|
+
assert 'AUTO' in msg or 'auto' in msg.lower()
|
|
1450
|
+
|
|
1451
|
+
def test_includes_review_label_when_low_confidence(self):
|
|
1452
|
+
imp = {
|
|
1453
|
+
'id': 'imp-003',
|
|
1454
|
+
'confidence': 60,
|
|
1455
|
+
'type': 'hook_update',
|
|
1456
|
+
'targetFile': 'src/hooks/test.ts',
|
|
1457
|
+
'reason': 'low confidence',
|
|
1458
|
+
}
|
|
1459
|
+
msg = format_improvement_message(imp)
|
|
1460
|
+
assert 'review' in msg.lower() or 'confirm' in msg.lower()
|
|
1461
|
+
|
|
1462
|
+
class TestTelegramBot:
|
|
1463
|
+
def test_init_disabled_when_no_token(self):
|
|
1464
|
+
bot = TelegramBot(token='', chat_id='')
|
|
1465
|
+
assert not bot.enabled
|
|
1466
|
+
|
|
1467
|
+
def test_init_enabled_when_token_provided(self):
|
|
1468
|
+
bot = TelegramBot(token='test-token', chat_id='12345')
|
|
1469
|
+
assert bot.enabled
|
|
1470
|
+
|
|
1471
|
+
@pytest.mark.asyncio
|
|
1472
|
+
async def test_send_message_returns_false_when_disabled(self):
|
|
1473
|
+
bot = TelegramBot(token='', chat_id='')
|
|
1474
|
+
result = await bot.send_message('test')
|
|
1475
|
+
assert result is False
|
|
1476
|
+
```
|
|
1477
|
+
|
|
1478
|
+
**Step 2: 运行测试确认失败**
|
|
1479
|
+
|
|
1480
|
+
```bash
|
|
1481
|
+
cd nexus-daemon && pip install pytest pytest-asyncio && python -m pytest tests/test_telegram_bot.py -v
|
|
1482
|
+
```
|
|
1483
|
+
Expected: FAIL — `ModuleNotFoundError: No module named 'telegram_bot'`
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
**Step 3: 实现 telegram_bot.py**
|
|
1488
|
+
|
|
1489
|
+
```python
|
|
1490
|
+
# nexus-daemon/telegram_bot.py
|
|
1491
|
+
"""
|
|
1492
|
+
Telegram Bot: 发送改进通知,接收用户确认指令。
|
|
1493
|
+
"""
|
|
1494
|
+
from __future__ import annotations
|
|
1495
|
+
|
|
1496
|
+
import logging
|
|
1497
|
+
from typing import Any
|
|
1498
|
+
|
|
1499
|
+
logger = logging.getLogger('nexus.telegram')
|
|
1500
|
+
|
|
1501
|
+
AUTO_APPLY_THRESHOLD = 80
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def format_improvement_message(imp: dict[str, Any], auto_apply_threshold: int = AUTO_APPLY_THRESHOLD) -> str:
|
|
1505
|
+
"""格式化改进建议为 Telegram 消息。"""
|
|
1506
|
+
confidence = imp.get('confidence', 0)
|
|
1507
|
+
label = 'AUTO-APPLY' if confidence >= auto_apply_threshold else 'needs review/confirm'
|
|
1508
|
+
return (
|
|
1509
|
+
f"[{label}] nexus improvement\n"
|
|
1510
|
+
f"ID: {imp.get('id', '?')}\n"
|
|
1511
|
+
f"Type: {imp.get('type', '?')}\n"
|
|
1512
|
+
f"File: {imp.get('targetFile', '?')}\n"
|
|
1513
|
+
f"Confidence: {confidence}\n"
|
|
1514
|
+
f"Reason: {imp.get('reason', '?')}"
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
class TelegramBot:
|
|
1519
|
+
"""Telegram Bot 客户端,用于发送通知和接收确认。"""
|
|
1520
|
+
|
|
1521
|
+
API_BASE = 'https://api.telegram.org/bot{token}/{method}'
|
|
1522
|
+
|
|
1523
|
+
def __init__(self, token: str, chat_id: str):
|
|
1524
|
+
self.enabled = bool(token and chat_id)
|
|
1525
|
+
self._token = token
|
|
1526
|
+
self._chat_id = chat_id
|
|
1527
|
+
|
|
1528
|
+
async def send_message(self, text: str) -> bool:
|
|
1529
|
+
"""发送消息到 Telegram。返回 True 表示成功。"""
|
|
1530
|
+
if not self.enabled:
|
|
1531
|
+
return False
|
|
1532
|
+
try:
|
|
1533
|
+
import aiohttp
|
|
1534
|
+
url = self.API_BASE.format(token=self._token, method='sendMessage')
|
|
1535
|
+
payload = {'chat_id': self._chat_id, 'text': text, 'parse_mode': 'HTML'}
|
|
1536
|
+
async with aiohttp.ClientSession() as session:
|
|
1537
|
+
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
|
1538
|
+
if resp.status == 200:
|
|
1539
|
+
logger.info('Telegram message sent')
|
|
1540
|
+
return True
|
|
1541
|
+
logger.warning('Telegram API error: %s', resp.status)
|
|
1542
|
+
return False
|
|
1543
|
+
except Exception as e:
|
|
1544
|
+
logger.error('Telegram send error: %s', e)
|
|
1545
|
+
return False
|
|
1546
|
+
|
|
1547
|
+
async def notify_improvement(self, imp: dict[str, Any]) -> bool:
|
|
1548
|
+
"""发送改进建议通知。"""
|
|
1549
|
+
msg = format_improvement_message(imp)
|
|
1550
|
+
return await self.send_message(msg)
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
**Step 4: 集成到 daemon.py**
|
|
1554
|
+
|
|
1555
|
+
在 `NexusDaemon.__init__` 末尾添加:
|
|
1556
|
+
|
|
1557
|
+
```python
|
|
1558
|
+
from telegram_bot import TelegramBot
|
|
1559
|
+
self._telegram = TelegramBot(
|
|
1560
|
+
token=self.config.telegram_token,
|
|
1561
|
+
chat_id=self.config.telegram_chat_id,
|
|
1562
|
+
)
|
|
1563
|
+
self._notified_improvements: set[str] = set() # dedup: avoid re-notifying same improvement
|
|
1564
|
+
```
|
|
1565
|
+
|
|
1566
|
+
在 `run_once` 的进化引擎调用后追加:
|
|
1567
|
+
|
|
1568
|
+
```python
|
|
1569
|
+
# Notify via Telegram for improvements needing review (with deduplication)
|
|
1570
|
+
if self._telegram.enabled:
|
|
1571
|
+
improvements_dir = self.repo_path / 'improvements'
|
|
1572
|
+
import json as _json
|
|
1573
|
+
for f in sorted(improvements_dir.glob('*.json')):
|
|
1574
|
+
imp_id = f.stem
|
|
1575
|
+
if imp_id in self._notified_improvements:
|
|
1576
|
+
continue # already notified this improvement
|
|
1577
|
+
try:
|
|
1578
|
+
imp = _json.loads(f.read_text())
|
|
1579
|
+
if imp.get('status') == 'pending':
|
|
1580
|
+
sent = await self._telegram.notify_improvement(imp)
|
|
1581
|
+
if sent:
|
|
1582
|
+
self._notified_improvements.add(imp_id)
|
|
1583
|
+
except Exception:
|
|
1584
|
+
pass
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1587
|
+
**Step 5: 运行测试确认通过**
|
|
1588
|
+
|
|
1589
|
+
```bash
|
|
1590
|
+
cd nexus-daemon && python -m pytest tests/test_telegram_bot.py -v
|
|
1591
|
+
```
|
|
1592
|
+
Expected: PASS (6 tests)
|
|
1593
|
+
|
|
1594
|
+
**Step 6: Commit**
|
|
1595
|
+
|
|
1596
|
+
```bash
|
|
1597
|
+
git add nexus-daemon/telegram_bot.py nexus-daemon/tests/test_telegram_bot.py nexus-daemon/daemon.py
|
|
1598
|
+
git commit -m "feat(nexus): add Telegram Bot notifications"
|
|
1599
|
+
```
|
|
1600
|
+
|
|
1601
|
+
---
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
---
|
|
1605
|
+
|
|
1606
|
+
### Task 10: Consciousness Loop(后台意识循环)
|
|
1607
|
+
|
|
1608
|
+
**Files:**
|
|
1609
|
+
- Create: `nexus-daemon/tests/test_consciousness_loop.py`
|
|
1610
|
+
- Create: `nexus-daemon/consciousness_loop.py`
|
|
1611
|
+
- Modify: `nexus-daemon/daemon.py`
|
|
1612
|
+
|
|
1613
|
+
**Step 1: 编写失败测试**
|
|
1614
|
+
|
|
1615
|
+
```python
|
|
1616
|
+
# nexus-daemon/tests/test_consciousness_loop.py
|
|
1617
|
+
import pytest
|
|
1618
|
+
from pathlib import Path
|
|
1619
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
1620
|
+
import json
|
|
1621
|
+
|
|
1622
|
+
from consciousness_loop import ConsciousnessLoop, ConsciousnessConfig
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
@pytest.fixture
|
|
1626
|
+
def tmp_repo(tmp_path):
|
|
1627
|
+
(tmp_path / 'consciousness').mkdir()
|
|
1628
|
+
(tmp_path / 'consciousness' / 'rounds').mkdir()
|
|
1629
|
+
return tmp_path
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def make_loop(tmp_repo):
|
|
1633
|
+
cfg = ConsciousnessConfig(
|
|
1634
|
+
interval_seconds=300,
|
|
1635
|
+
budget_percent=10,
|
|
1636
|
+
max_rounds_per_session=5,
|
|
1637
|
+
)
|
|
1638
|
+
return ConsciousnessLoop(repo_path=tmp_repo, config=cfg)
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def test_scratchpad_path(tmp_repo):
|
|
1642
|
+
loop = make_loop(tmp_repo)
|
|
1643
|
+
assert loop.scratchpad_path == tmp_repo / 'consciousness' / 'scratchpad.md'
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def test_rounds_dir(tmp_repo):
|
|
1647
|
+
loop = make_loop(tmp_repo)
|
|
1648
|
+
assert loop.rounds_dir == tmp_repo / 'consciousness' / 'rounds'
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def test_write_scratchpad(tmp_repo):
|
|
1652
|
+
loop = make_loop(tmp_repo)
|
|
1653
|
+
loop._write_scratchpad('test content')
|
|
1654
|
+
assert loop.scratchpad_path.read_text() == 'test content'
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def test_write_round_record(tmp_repo):
|
|
1658
|
+
loop = make_loop(tmp_repo)
|
|
1659
|
+
loop._write_round_record(round_num=1, content='round 1 thoughts')
|
|
1660
|
+
files = list(loop.rounds_dir.glob('round-*.md'))
|
|
1661
|
+
assert len(files) == 1
|
|
1662
|
+
assert 'round 1 thoughts' in files[0].read_text()
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def test_is_paused_when_busy(tmp_repo):
|
|
1666
|
+
loop = make_loop(tmp_repo)
|
|
1667
|
+
# Create a busy marker
|
|
1668
|
+
(tmp_repo / '.nexus-busy').write_text('1')
|
|
1669
|
+
assert loop.is_paused() is True
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
def test_not_paused_when_idle(tmp_repo):
|
|
1673
|
+
loop = make_loop(tmp_repo)
|
|
1674
|
+
assert loop.is_paused() is False
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
**Step 2: 运行测试确认失败**
|
|
1678
|
+
|
|
1679
|
+
```bash
|
|
1680
|
+
cd nexus-daemon && python -m pytest tests/test_consciousness_loop.py -v
|
|
1681
|
+
```
|
|
1682
|
+
Expected: FAIL with `ModuleNotFoundError: No module named 'consciousness_loop'`
|
|
1683
|
+
|
|
1684
|
+
**Step 3: 实现 `nexus-daemon/consciousness_loop.py`**
|
|
1685
|
+
|
|
1686
|
+
```python
|
|
1687
|
+
# nexus-daemon/consciousness_loop.py
|
|
1688
|
+
from __future__ import annotations
|
|
1689
|
+
import asyncio
|
|
1690
|
+
from dataclasses import dataclass, field
|
|
1691
|
+
from datetime import datetime
|
|
1692
|
+
from pathlib import Path
|
|
1693
|
+
from typing import Optional
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
@dataclass
|
|
1697
|
+
class ConsciousnessConfig:
|
|
1698
|
+
interval_seconds: int = 300
|
|
1699
|
+
budget_percent: int = 10
|
|
1700
|
+
max_rounds_per_session: int = 5
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
class ConsciousnessLoop:
|
|
1704
|
+
def __init__(self, repo_path: Path, config: ConsciousnessConfig):
|
|
1705
|
+
self.repo_path = repo_path
|
|
1706
|
+
self.config = config
|
|
1707
|
+
self._round_count = 0
|
|
1708
|
+
|
|
1709
|
+
@property
|
|
1710
|
+
def scratchpad_path(self) -> Path:
|
|
1711
|
+
return self.repo_path / 'consciousness' / 'scratchpad.md'
|
|
1712
|
+
|
|
1713
|
+
@property
|
|
1714
|
+
def rounds_dir(self) -> Path:
|
|
1715
|
+
return self.repo_path / 'consciousness' / 'rounds'
|
|
1716
|
+
|
|
1717
|
+
def is_paused(self) -> bool:
|
|
1718
|
+
return (self.repo_path / '.nexus-busy').exists()
|
|
1719
|
+
|
|
1720
|
+
def _write_scratchpad(self, content: str) -> None:
|
|
1721
|
+
self.scratchpad_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1722
|
+
self.scratchpad_path.write_text(content)
|
|
1723
|
+
|
|
1724
|
+
def _write_round_record(self, round_num: int, content: str) -> None:
|
|
1725
|
+
self.rounds_dir.mkdir(parents=True, exist_ok=True)
|
|
1726
|
+
ts = datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
|
1727
|
+
path = self.rounds_dir / f'round-{round_num:04d}-{ts}.md'
|
|
1728
|
+
path.write_text(content)
|
|
1729
|
+
|
|
1730
|
+
async def run_once(self) -> Optional[str]:
|
|
1731
|
+
"""Run one consciousness round. Returns scratchpad content or None if paused."""
|
|
1732
|
+
if self.is_paused():
|
|
1733
|
+
return None
|
|
1734
|
+
if self._round_count >= self.config.max_rounds_per_session:
|
|
1735
|
+
return None
|
|
1736
|
+
|
|
1737
|
+
self._round_count += 1
|
|
1738
|
+
ts = datetime.utcnow().isoformat()
|
|
1739
|
+
content = (
|
|
1740
|
+
f'# Consciousness Round {self._round_count}\n\n'
|
|
1741
|
+
f'**Timestamp:** {ts}\n\n'
|
|
1742
|
+
f'*Awaiting LLM reflection...*\n'
|
|
1743
|
+
)
|
|
1744
|
+
self._write_scratchpad(content)
|
|
1745
|
+
self._write_round_record(self._round_count, content)
|
|
1746
|
+
return content
|
|
1747
|
+
|
|
1748
|
+
async def run_loop(self) -> None:
|
|
1749
|
+
"""Continuous loop: run_once every interval_seconds."""
|
|
1750
|
+
while True:
|
|
1751
|
+
await self.run_once()
|
|
1752
|
+
await asyncio.sleep(self.config.interval_seconds)
|
|
1753
|
+
```
|
|
1754
|
+
|
|
1755
|
+
**Step 4: 集成到 `nexus-daemon/daemon.py`**
|
|
1756
|
+
|
|
1757
|
+
在 `NexusDaemon.__init__` 中添加:
|
|
1758
|
+
```python
|
|
1759
|
+
from consciousness_loop import ConsciousnessLoop, ConsciousnessConfig
|
|
1760
|
+
|
|
1761
|
+
# 在 __init__ 末尾添加:
|
|
1762
|
+
self._consciousness = ConsciousnessLoop(
|
|
1763
|
+
repo_path=self.repo_path,
|
|
1764
|
+
config=ConsciousnessConfig(
|
|
1765
|
+
interval_seconds=self.config.consciousness_interval,
|
|
1766
|
+
budget_percent=self.config.consciousness_budget_percent,
|
|
1767
|
+
),
|
|
1768
|
+
)
|
|
1769
|
+
```
|
|
1770
|
+
|
|
1771
|
+
在 `run()` 方法中并行启动意识循环:
|
|
1772
|
+
```python
|
|
1773
|
+
async def run(self) -> None:
|
|
1774
|
+
"""Main daemon loop."""
|
|
1775
|
+
tasks = [
|
|
1776
|
+
asyncio.create_task(self._main_loop()),
|
|
1777
|
+
asyncio.create_task(self._consciousness.run_loop()),
|
|
1778
|
+
]
|
|
1779
|
+
await asyncio.gather(*tasks)
|
|
1780
|
+
|
|
1781
|
+
async def _main_loop(self) -> None:
|
|
1782
|
+
"""Git pull + evolution engine loop."""
|
|
1783
|
+
while True:
|
|
1784
|
+
await self.run_once()
|
|
1785
|
+
await asyncio.sleep(60)
|
|
1786
|
+
```
|
|
1787
|
+
|
|
1788
|
+
**Step 5: 运行测试确认通过**
|
|
1789
|
+
|
|
1790
|
+
```bash
|
|
1791
|
+
cd nexus-daemon && python -m pytest tests/test_consciousness_loop.py -v
|
|
1792
|
+
```
|
|
1793
|
+
Expected: PASS (6 tests)
|
|
1794
|
+
|
|
1795
|
+
**Step 6: Commit**
|
|
1796
|
+
|
|
1797
|
+
```bash
|
|
1798
|
+
git add nexus-daemon/consciousness_loop.py nexus-daemon/tests/test_consciousness_loop.py nexus-daemon/daemon.py
|
|
1799
|
+
git commit -m "feat(nexus): add Consciousness Loop background awareness"
|
|
1800
|
+
```
|
|
1801
|
+
|
|
1802
|
+
---
|
|
1803
|
+
|
|
1804
|
+
### Task 11: Self-Evaluator(健康报告)
|
|
1805
|
+
|
|
1806
|
+
**Files:**
|
|
1807
|
+
- Create: `nexus-daemon/tests/test_self_evaluator.py`
|
|
1808
|
+
- Create: `nexus-daemon/self_evaluator.py`
|
|
1809
|
+
- Modify: `nexus-daemon/daemon.py`
|
|
1810
|
+
|
|
1811
|
+
**Step 1: 编写失败测试**
|
|
1812
|
+
|
|
1813
|
+
```python
|
|
1814
|
+
# nexus-daemon/tests/test_self_evaluator.py
|
|
1815
|
+
import pytest
|
|
1816
|
+
from pathlib import Path
|
|
1817
|
+
import json
|
|
1818
|
+
from self_evaluator import SelfEvaluator, SkillStats, HealthReport
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
@pytest.fixture
|
|
1822
|
+
def tmp_repo(tmp_path):
|
|
1823
|
+
events_dir = tmp_path / 'events'
|
|
1824
|
+
events_dir.mkdir()
|
|
1825
|
+
return tmp_path
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def make_event(session_id: str, skills_triggered: list[str]) -> dict:
|
|
1829
|
+
return {
|
|
1830
|
+
'sessionId': session_id,
|
|
1831
|
+
'timestamp': '2026-02-26T10:00:00Z',
|
|
1832
|
+
'skillsTriggered': skills_triggered,
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
def test_empty_events_returns_empty_report(tmp_repo):
|
|
1837
|
+
ev = SelfEvaluator(repo_path=tmp_repo)
|
|
1838
|
+
report = ev.generate_report()
|
|
1839
|
+
assert isinstance(report, HealthReport)
|
|
1840
|
+
assert report.total_sessions == 0
|
|
1841
|
+
assert report.skill_stats == {}
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def test_counts_skill_usage(tmp_repo):
|
|
1845
|
+
(tmp_repo / 'events').mkdir(exist_ok=True)
|
|
1846
|
+
evt = make_event('s1', ['learner', 'autopilot'])
|
|
1847
|
+
(tmp_repo / 'events' / 's1.json').write_text(json.dumps(evt))
|
|
1848
|
+
ev = SelfEvaluator(repo_path=tmp_repo)
|
|
1849
|
+
report = ev.generate_report()
|
|
1850
|
+
assert report.skill_stats['learner'].trigger_count == 1
|
|
1851
|
+
assert report.skill_stats['autopilot'].trigger_count == 1
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
def test_detects_zombie_skills(tmp_repo):
|
|
1855
|
+
(tmp_repo / 'events').mkdir(exist_ok=True)
|
|
1856
|
+
# 10 sessions, none trigger 'zombie-skill'
|
|
1857
|
+
for i in range(10):
|
|
1858
|
+
evt = make_event(f's{i}', ['learner'])
|
|
1859
|
+
(tmp_repo / 'events' / f's{i}.json').write_text(json.dumps(evt))
|
|
1860
|
+
ev = SelfEvaluator(repo_path=tmp_repo, zombie_threshold=5)
|
|
1861
|
+
report = ev.generate_report()
|
|
1862
|
+
assert 'zombie-skill' not in report.skill_stats
|
|
1863
|
+
assert report.total_sessions == 10
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
def test_format_report_markdown(tmp_repo):
|
|
1867
|
+
ev = SelfEvaluator(repo_path=tmp_repo)
|
|
1868
|
+
report = HealthReport(total_sessions=3, skill_stats={}, zombie_skills=[])
|
|
1869
|
+
md = ev.format_report(report)
|
|
1870
|
+
assert '# nexus Health Report' in md
|
|
1871
|
+
assert 'Total sessions: 3' in md
|
|
1872
|
+
```
|
|
1873
|
+
|
|
1874
|
+
**Step 2: 运行测试确认失败**
|
|
1875
|
+
|
|
1876
|
+
```bash
|
|
1877
|
+
cd nexus-daemon && python -m pytest tests/test_self_evaluator.py -v
|
|
1878
|
+
```
|
|
1879
|
+
Expected: FAIL with `ModuleNotFoundError: No module named 'self_evaluator'`
|
|
1880
|
+
|
|
1881
|
+
**Step 3: 实现 `nexus-daemon/self_evaluator.py`**
|
|
1882
|
+
|
|
1883
|
+
```python
|
|
1884
|
+
# nexus-daemon/self_evaluator.py
|
|
1885
|
+
from __future__ import annotations
|
|
1886
|
+
import json
|
|
1887
|
+
from dataclasses import dataclass, field
|
|
1888
|
+
from datetime import datetime
|
|
1889
|
+
from pathlib import Path
|
|
1890
|
+
from typing import Optional
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
@dataclass
|
|
1894
|
+
class SkillStats:
|
|
1895
|
+
trigger_count: int = 0
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
@dataclass
|
|
1899
|
+
class HealthReport:
|
|
1900
|
+
total_sessions: int
|
|
1901
|
+
skill_stats: dict[str, SkillStats]
|
|
1902
|
+
zombie_skills: list[str] = field(default_factory=list)
|
|
1903
|
+
generated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
class SelfEvaluator:
|
|
1907
|
+
def __init__(self, repo_path: Path, zombie_threshold: int = 10):
|
|
1908
|
+
self.repo_path = repo_path
|
|
1909
|
+
self.zombie_threshold = zombie_threshold
|
|
1910
|
+
|
|
1911
|
+
def _load_events(self) -> list[dict]:
|
|
1912
|
+
events_dir = self.repo_path / 'events'
|
|
1913
|
+
if not events_dir.exists():
|
|
1914
|
+
return []
|
|
1915
|
+
events = []
|
|
1916
|
+
for f in events_dir.glob('*.json'):
|
|
1917
|
+
try:
|
|
1918
|
+
events.append(json.loads(f.read_text()))
|
|
1919
|
+
except Exception:
|
|
1920
|
+
pass
|
|
1921
|
+
return events
|
|
1922
|
+
|
|
1923
|
+
def generate_report(self) -> HealthReport:
|
|
1924
|
+
events = self._load_events()
|
|
1925
|
+
skill_stats: dict[str, SkillStats] = {}
|
|
1926
|
+
|
|
1927
|
+
for evt in events:
|
|
1928
|
+
for skill in evt.get('skillsTriggered', []):
|
|
1929
|
+
if skill not in skill_stats:
|
|
1930
|
+
skill_stats[skill] = SkillStats()
|
|
1931
|
+
skill_stats[skill].trigger_count += 1
|
|
1932
|
+
|
|
1933
|
+
# Detect zombie skills: sessions >= threshold but skill never triggered
|
|
1934
|
+
zombie_skills: list[str] = []
|
|
1935
|
+
if len(events) >= self.zombie_threshold:
|
|
1936
|
+
all_known = set()
|
|
1937
|
+
for evt in events:
|
|
1938
|
+
all_known.update(evt.get('skillsAvailable', []))
|
|
1939
|
+
for skill in all_known:
|
|
1940
|
+
if skill not in skill_stats or skill_stats[skill].trigger_count == 0:
|
|
1941
|
+
zombie_skills.append(skill)
|
|
1942
|
+
|
|
1943
|
+
return HealthReport(
|
|
1944
|
+
total_sessions=len(events),
|
|
1945
|
+
skill_stats=skill_stats,
|
|
1946
|
+
zombie_skills=sorted(zombie_skills),
|
|
1947
|
+
)
|
|
1948
|
+
|
|
1949
|
+
def format_report(self, report: HealthReport) -> str:
|
|
1950
|
+
lines = [
|
|
1951
|
+
'# nexus Health Report',
|
|
1952
|
+
f'',
|
|
1953
|
+
f'Generated: {report.generated_at}',
|
|
1954
|
+
f'Total sessions: {report.total_sessions}',
|
|
1955
|
+
'',
|
|
1956
|
+
'## Skill Usage',
|
|
1957
|
+
]
|
|
1958
|
+
if not report.skill_stats:
|
|
1959
|
+
lines.append('No skill usage recorded.')
|
|
1960
|
+
else:
|
|
1961
|
+
for skill, stats in sorted(report.skill_stats.items()):
|
|
1962
|
+
lines.append(f'- `{skill}`: {stats.trigger_count} triggers')
|
|
1963
|
+
if report.zombie_skills:
|
|
1964
|
+
lines += ['', '## Zombie Skills (never triggered)', '']
|
|
1965
|
+
for z in report.zombie_skills:
|
|
1966
|
+
lines.append(f'- `{z}`')
|
|
1967
|
+
return '\n'.join(lines)
|
|
1968
|
+
```
|
|
1969
|
+
|
|
1970
|
+
**Step 4: 集成到 `nexus-daemon/daemon.py`**
|
|
1971
|
+
|
|
1972
|
+
在 `run_once()` 末尾添加每日健康报告逻辑:
|
|
1973
|
+
```python
|
|
1974
|
+
from self_evaluator import SelfEvaluator
|
|
1975
|
+
|
|
1976
|
+
# 在 __init__ 末尾:
|
|
1977
|
+
self._evaluator = SelfEvaluator(repo_path=self.repo_path)
|
|
1978
|
+
self._last_report_date: str = ''
|
|
1979
|
+
|
|
1980
|
+
# 在 run_once() 末尾:
|
|
1981
|
+
today = datetime.utcnow().strftime('%Y-%m-%d')
|
|
1982
|
+
if today != self._last_report_date:
|
|
1983
|
+
self._last_report_date = today
|
|
1984
|
+
report = self._evaluator.generate_report()
|
|
1985
|
+
md = self._evaluator.format_report(report)
|
|
1986
|
+
report_path = self.repo_path / 'consciousness' / f'health-{today}.md'
|
|
1987
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1988
|
+
report_path.write_text(md)
|
|
1989
|
+
if self._telegram.enabled:
|
|
1990
|
+
await self._telegram.send_message(f'Daily health report ready: {today}')
|
|
1991
|
+
```
|
|
1992
|
+
|
|
1993
|
+
**Step 5: 运行测试确认通过**
|
|
1994
|
+
|
|
1995
|
+
```bash
|
|
1996
|
+
cd nexus-daemon && python -m pytest tests/test_self_evaluator.py -v
|
|
1997
|
+
```
|
|
1998
|
+
Expected: PASS (4 tests)
|
|
1999
|
+
|
|
2000
|
+
**Step 6: Commit**
|
|
2001
|
+
|
|
2002
|
+
```bash
|
|
2003
|
+
git add nexus-daemon/self_evaluator.py nexus-daemon/tests/test_self_evaluator.py nexus-daemon/daemon.py
|
|
2004
|
+
git commit -m "feat(nexus): add Self-Evaluator health reports"
|
|
2005
|
+
```
|
|
2006
|
+
|
|
2007
|
+
---
|
|
2008
|
+
|
|
2009
|
+
### Task 12: nexus 管理 Skills(nexus-status / nexus-evolve / nexus-review)
|
|
2010
|
+
|
|
2011
|
+
**Files:**
|
|
2012
|
+
- Create: `skills/nexus/nexus-status/SKILL.md`
|
|
2013
|
+
- Create: `skills/nexus/nexus-evolve/SKILL.md`
|
|
2014
|
+
- Create: `skills/nexus/nexus-review/SKILL.md`
|
|
2015
|
+
|
|
2016
|
+
**Step 1: 创建 `skills/nexus/nexus-status/SKILL.md`**
|
|
2017
|
+
|
|
2018
|
+
```markdown
|
|
2019
|
+
---
|
|
2020
|
+
name: nexus-status
|
|
2021
|
+
description: 查看 nexus 系统状态
|
|
2022
|
+
triggers:
|
|
2023
|
+
- nexus status
|
|
2024
|
+
- nexus-status
|
|
2025
|
+
- nexus health
|
|
2026
|
+
---
|
|
2027
|
+
|
|
2028
|
+
# nexus Status
|
|
2029
|
+
|
|
2030
|
+
显示 nexus 系统当前状态。
|
|
2031
|
+
|
|
2032
|
+
## 执行步骤
|
|
2033
|
+
|
|
2034
|
+
1. 读取 `.omc/nexus/config.json` 确认 nexus 是否启用
|
|
2035
|
+
2. 检查 `.omc/nexus/events/` 目录,统计待推送事件数量
|
|
2036
|
+
3. 检查 `.omc/nexus/improvements/` 目录,统计待审批改进数量
|
|
2037
|
+
4. 输出状态摘要:
|
|
2038
|
+
|
|
2039
|
+
```
|
|
2040
|
+
nexus Status
|
|
2041
|
+
============
|
|
2042
|
+
Enabled: true/false
|
|
2043
|
+
Pending events: N
|
|
2044
|
+
Pending improvements: N
|
|
2045
|
+
Last sync: <timestamp or "never">
|
|
2046
|
+
```
|
|
2047
|
+
|
|
2048
|
+
如果 nexus 未启用,提示用户配置 `.omc/nexus/config.json`。
|
|
2049
|
+
```
|
|
2050
|
+
|
|
2051
|
+
**Step 2: 创建 `skills/nexus/nexus-evolve/SKILL.md`**
|
|
2052
|
+
|
|
2053
|
+
```markdown
|
|
2054
|
+
---
|
|
2055
|
+
name: nexus-evolve
|
|
2056
|
+
description: 手动触发 nexus 进化引擎
|
|
2057
|
+
triggers:
|
|
2058
|
+
- nexus evolve
|
|
2059
|
+
- nexus-evolve
|
|
2060
|
+
- trigger evolution
|
|
2061
|
+
---
|
|
2062
|
+
|
|
2063
|
+
# nexus Evolve
|
|
2064
|
+
|
|
2065
|
+
手动触发 nexus 进化引擎,立即处理积累的会话数据。
|
|
2066
|
+
|
|
2067
|
+
## 执行步骤
|
|
2068
|
+
|
|
2069
|
+
1. 检查 `.omc/nexus/events/` 是否有未处理事件
|
|
2070
|
+
2. 如果有,执行 git push 将事件推送到 nexus-daemon 仓库
|
|
2071
|
+
3. 提示用户:进化引擎将在 VPS 端处理这些事件
|
|
2072
|
+
4. 等待约 2 分钟后,执行 git pull 拉取改进建议
|
|
2073
|
+
5. 如果有新的 `.omc/nexus/improvements/` 文件,显示摘要
|
|
2074
|
+
|
|
2075
|
+
## 输出格式
|
|
2076
|
+
|
|
2077
|
+
```
|
|
2078
|
+
nexus Evolve triggered
|
|
2079
|
+
======================
|
|
2080
|
+
Events pushed: N
|
|
2081
|
+
Waiting for VPS processing...
|
|
2082
|
+
Improvements received: N
|
|
2083
|
+
Run /nexus-review to review pending improvements.
|
|
2084
|
+
```
|
|
2085
|
+
```
|
|
2086
|
+
|
|
2087
|
+
**Step 3: 创建 `skills/nexus/nexus-review/SKILL.md`**
|
|
2088
|
+
|
|
2089
|
+
```markdown
|
|
2090
|
+
---
|
|
2091
|
+
name: nexus-review
|
|
2092
|
+
description: 查看并审批 nexus 生成的改进建议
|
|
2093
|
+
triggers:
|
|
2094
|
+
- nexus review
|
|
2095
|
+
- nexus-review
|
|
2096
|
+
- review improvements
|
|
2097
|
+
---
|
|
2098
|
+
|
|
2099
|
+
# nexus Review
|
|
2100
|
+
|
|
2101
|
+
查看 nexus 系统生成的待审批改进建议。
|
|
2102
|
+
|
|
2103
|
+
## 执行步骤
|
|
2104
|
+
|
|
2105
|
+
1. 读取 `.omc/nexus/improvements/` 下所有 `status === "pending"` 的文件
|
|
2106
|
+
2. 按置信度降序排列
|
|
2107
|
+
3. 对每个改进建议显示:
|
|
2108
|
+
- ID、类型、目标文件
|
|
2109
|
+
- 置信度(≥80 标记为 AUTO-APPLY)
|
|
2110
|
+
- 原因和证据摘要
|
|
2111
|
+
- diff 内容
|
|
2112
|
+
|
|
2113
|
+
4. 询问用户对每个建议的操作:
|
|
2114
|
+
- `apply`:应用改进,更新 status 为 `applied`
|
|
2115
|
+
- `skip`:跳过,更新 status 为 `rejected`
|
|
2116
|
+
- `auto`:自动应用所有置信度 ≥80 的建议
|
|
2117
|
+
|
|
2118
|
+
## 输出格式
|
|
2119
|
+
|
|
2120
|
+
```
|
|
2121
|
+
nexus Improvements (N pending)
|
|
2122
|
+
==============================
|
|
2123
|
+
[1] skill_update | skills/learner/SKILL.md | confidence: 87 [AUTO-APPLY]
|
|
2124
|
+
Reason: 触发词 'learn' 在过去 10 次会话中出现 23 次但未触发
|
|
2125
|
+
Action? (apply/skip/auto):
|
|
2126
|
+
```
|
|
2127
|
+
```
|
|
2128
|
+
|
|
2129
|
+
**Step 4: 验证 skill 文件格式正确**
|
|
2130
|
+
|
|
2131
|
+
```bash
|
|
2132
|
+
# 确认文件存在
|
|
2133
|
+
ls skills/nexus/nexus-status/SKILL.md skills/nexus/nexus-evolve/SKILL.md skills/nexus/nexus-review/SKILL.md
|
|
2134
|
+
```
|
|
2135
|
+
Expected: 3 files listed without error
|
|
2136
|
+
|
|
2137
|
+
```bash
|
|
2138
|
+
# 确认每个文件包含必要的 frontmatter 字段
|
|
2139
|
+
grep -l "^name:" skills/nexus/*/SKILL.md | wc -l
|
|
2140
|
+
grep -l "^triggers:" skills/nexus/*/SKILL.md | wc -l
|
|
2141
|
+
```
|
|
2142
|
+
Expected: 两条命令均输出 `3`
|
|
2143
|
+
|
|
2144
|
+
**Step 5: Commit**
|
|
2145
|
+
|
|
2146
|
+
```bash
|
|
2147
|
+
git add skills/nexus/
|
|
2148
|
+
git commit -m "feat(nexus): add nexus-status, nexus-evolve, nexus-review management skills"
|
|
2149
|
+
```
|
|
2150
|
+
|
|
2151
|
+
---
|
|
2152
|
+
|
|
2153
|
+
### Task 13: systemd 服务文件(VPS 部署)
|
|
2154
|
+
|
|
2155
|
+
**Files:**
|
|
2156
|
+
- Create: `nexus-daemon/nexus-daemon.service`
|
|
2157
|
+
- Create: `nexus-daemon/install.sh`
|
|
2158
|
+
|
|
2159
|
+
**Step 1: 创建 `nexus-daemon/nexus-daemon.service`**
|
|
2160
|
+
|
|
2161
|
+
```ini
|
|
2162
|
+
[Unit]
|
|
2163
|
+
Description=nexus Daemon - ultrapower self-improvement VPS service
|
|
2164
|
+
After=network.target
|
|
2165
|
+
Wants=network-online.target
|
|
2166
|
+
|
|
2167
|
+
[Service]
|
|
2168
|
+
Type=simple
|
|
2169
|
+
User=nexus
|
|
2170
|
+
WorkingDirectory=/opt/nexus-daemon
|
|
2171
|
+
ExecStart=/opt/nexus-daemon/venv/bin/python daemon.py
|
|
2172
|
+
Restart=on-failure
|
|
2173
|
+
RestartSec=10
|
|
2174
|
+
StandardOutput=journal
|
|
2175
|
+
StandardError=journal
|
|
2176
|
+
SyslogIdentifier=nexus-daemon
|
|
2177
|
+
|
|
2178
|
+
# Environment
|
|
2179
|
+
Environment=PYTHONUNBUFFERED=1
|
|
2180
|
+
|
|
2181
|
+
[Install]
|
|
2182
|
+
WantedBy=multi-user.target
|
|
2183
|
+
```
|
|
2184
|
+
|
|
2185
|
+
**Step 2: 创建 `nexus-daemon/install.sh`**
|
|
2186
|
+
|
|
2187
|
+
```bash
|
|
2188
|
+
#!/usr/bin/env bash
|
|
2189
|
+
set -euo pipefail
|
|
2190
|
+
|
|
2191
|
+
INSTALL_DIR=/opt/nexus-daemon
|
|
2192
|
+
SERVICE_FILE=/etc/systemd/system/nexus-daemon.service
|
|
2193
|
+
|
|
2194
|
+
echo "Installing nexus-daemon..."
|
|
2195
|
+
|
|
2196
|
+
# Create user
|
|
2197
|
+
id nexus &>/dev/null || useradd -r -s /bin/false nexus
|
|
2198
|
+
|
|
2199
|
+
# Copy files
|
|
2200
|
+
mkdir -p "$INSTALL_DIR"
|
|
2201
|
+
cp -r . "$INSTALL_DIR/"
|
|
2202
|
+
chown -R nexus:nexus "$INSTALL_DIR"
|
|
2203
|
+
|
|
2204
|
+
# Create virtualenv and install deps
|
|
2205
|
+
python3 -m venv "$INSTALL_DIR/venv"
|
|
2206
|
+
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
|
|
2207
|
+
|
|
2208
|
+
# Install systemd service
|
|
2209
|
+
cp nexus-daemon.service "$SERVICE_FILE"
|
|
2210
|
+
systemctl daemon-reload
|
|
2211
|
+
systemctl enable nexus-daemon
|
|
2212
|
+
systemctl start nexus-daemon
|
|
2213
|
+
|
|
2214
|
+
echo "nexus-daemon installed and started."
|
|
2215
|
+
echo "Check status: systemctl status nexus-daemon"
|
|
2216
|
+
```
|
|
2217
|
+
|
|
2218
|
+
**Step 3: 验证文件内容**
|
|
2219
|
+
|
|
2220
|
+
```bash
|
|
2221
|
+
cat nexus-daemon/nexus-daemon.service | grep -E "^(Description|ExecStart|Restart)"
|
|
2222
|
+
```
|
|
2223
|
+
Expected:
|
|
2224
|
+
```
|
|
2225
|
+
Description=nexus Daemon - ultrapower self-improvement VPS service
|
|
2226
|
+
ExecStart=/opt/nexus-daemon/venv/bin/python daemon.py
|
|
2227
|
+
Restart=on-failure
|
|
2228
|
+
```
|
|
2229
|
+
|
|
2230
|
+
**Step 4: Commit**
|
|
2231
|
+
|
|
2232
|
+
```bash
|
|
2233
|
+
git add nexus-daemon/nexus-daemon.service nexus-daemon/install.sh
|
|
2234
|
+
git commit -m "feat(nexus): add systemd service file and install script"
|
|
2235
|
+
```
|
|
2236
|
+
|
|
2237
|
+
---
|
|
2238
|
+
|
|
2239
|
+
### Task 14: Self-Modifier(代码自动修改模块)
|
|
2240
|
+
|
|
2241
|
+
**Files:**
|
|
2242
|
+
- Create: `nexus-daemon/self_modifier.py`
|
|
2243
|
+
- Modify: `nexus-daemon/daemon.py`(在 `run_once` 中集成 Self-Modifier)
|
|
2244
|
+
- Create: `nexus-daemon/tests/test_self_modifier.py`
|
|
2245
|
+
|
|
2246
|
+
**Step 1: 写失败测试**
|
|
2247
|
+
|
|
2248
|
+
```python
|
|
2249
|
+
# nexus-daemon/tests/test_self_modifier.py
|
|
2250
|
+
import pytest
|
|
2251
|
+
import json
|
|
2252
|
+
from pathlib import Path
|
|
2253
|
+
from unittest.mock import patch, MagicMock
|
|
2254
|
+
import tempfile
|
|
2255
|
+
import shutil
|
|
2256
|
+
|
|
2257
|
+
from self_modifier import SelfModifier, ModificationResult
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
@pytest.fixture
|
|
2261
|
+
def repo(tmp_path):
|
|
2262
|
+
"""Create a minimal fake ultrapower repo."""
|
|
2263
|
+
skills_dir = tmp_path / 'skills' / 'learner'
|
|
2264
|
+
skills_dir.mkdir(parents=True)
|
|
2265
|
+
skill_file = skills_dir / 'SKILL.md'
|
|
2266
|
+
skill_file.write_text(
|
|
2267
|
+
'---\nname: learner\ntriggers:\n - learn\n---\n# Learner\n'
|
|
2268
|
+
)
|
|
2269
|
+
# Init git
|
|
2270
|
+
import subprocess
|
|
2271
|
+
subprocess.run(['git', 'init'], cwd=tmp_path, capture_output=True)
|
|
2272
|
+
subprocess.run(['git', 'add', '.'], cwd=tmp_path, capture_output=True)
|
|
2273
|
+
subprocess.run(
|
|
2274
|
+
['git', 'commit', '-m', 'init'],
|
|
2275
|
+
cwd=tmp_path, capture_output=True,
|
|
2276
|
+
env={**__import__('os').environ, 'GIT_AUTHOR_NAME': 'test',
|
|
2277
|
+
'GIT_AUTHOR_EMAIL': 'test@test.com',
|
|
2278
|
+
'GIT_COMMITTER_NAME': 'test',
|
|
2279
|
+
'GIT_COMMITTER_EMAIL': 'test@test.com'},
|
|
2280
|
+
)
|
|
2281
|
+
return tmp_path
|
|
2282
|
+
|
|
2283
|
+
|
|
2284
|
+
def test_apply_skill_update_low_confidence_skipped(repo):
|
|
2285
|
+
"""Improvements with confidence < 70 are skipped."""
|
|
2286
|
+
modifier = SelfModifier(repo_path=repo)
|
|
2287
|
+
improvement = {
|
|
2288
|
+
'id': 'imp-001',
|
|
2289
|
+
'type': 'skill_update',
|
|
2290
|
+
'targetFile': 'skills/learner/SKILL.md',
|
|
2291
|
+
'confidence': 60,
|
|
2292
|
+
'diff': '--- a/skills/learner/SKILL.md\n+++ b/skills/learner/SKILL.md\n'
|
|
2293
|
+
'@@ -3,1 +3,2 @@\n - learn\n+ - study\n',
|
|
2294
|
+
'reason': 'add synonym',
|
|
2295
|
+
}
|
|
2296
|
+
result = modifier.apply(improvement)
|
|
2297
|
+
assert result.status == 'skipped'
|
|
2298
|
+
assert result.reason == 'confidence below threshold'
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
def test_apply_skill_update_high_confidence_applies(repo):
|
|
2302
|
+
"""Improvements with confidence >= 70 on skill files are applied."""
|
|
2303
|
+
modifier = SelfModifier(repo_path=repo)
|
|
2304
|
+
new_content = (
|
|
2305
|
+
'---\nname: learner\ntriggers:\n - learn\n - study\n---\n# Learner\n'
|
|
2306
|
+
)
|
|
2307
|
+
improvement = {
|
|
2308
|
+
'id': 'imp-002',
|
|
2309
|
+
'type': 'skill_update',
|
|
2310
|
+
'targetFile': 'skills/learner/SKILL.md',
|
|
2311
|
+
'confidence': 75,
|
|
2312
|
+
'newContent': new_content,
|
|
2313
|
+
'reason': 'add synonym study',
|
|
2314
|
+
}
|
|
2315
|
+
result = modifier.apply(improvement)
|
|
2316
|
+
assert result.status == 'applied'
|
|
2317
|
+
applied_content = (repo / 'skills' / 'learner' / 'SKILL.md').read_text()
|
|
2318
|
+
assert 'study' in applied_content
|
|
2319
|
+
|
|
2320
|
+
|
|
2321
|
+
def test_apply_rejects_path_traversal(repo):
|
|
2322
|
+
"""Path traversal in targetFile is rejected."""
|
|
2323
|
+
modifier = SelfModifier(repo_path=repo)
|
|
2324
|
+
improvement = {
|
|
2325
|
+
'id': 'imp-003',
|
|
2326
|
+
'type': 'skill_update',
|
|
2327
|
+
'targetFile': '../../etc/passwd',
|
|
2328
|
+
'confidence': 90,
|
|
2329
|
+
'newContent': 'malicious',
|
|
2330
|
+
'reason': 'test',
|
|
2331
|
+
}
|
|
2332
|
+
result = modifier.apply(improvement)
|
|
2333
|
+
assert result.status == 'rejected'
|
|
2334
|
+
assert 'path traversal' in result.reason.lower()
|
|
2335
|
+
```
|
|
2336
|
+
|
|
2337
|
+
**Step 2: 运行测试确认失败**
|
|
2338
|
+
|
|
2339
|
+
```bash
|
|
2340
|
+
cd nexus-daemon && python -m pytest tests/test_self_modifier.py -v
|
|
2341
|
+
```
|
|
2342
|
+
Expected: FAIL — `ModuleNotFoundError: No module named 'self_modifier'`
|
|
2343
|
+
|
|
2344
|
+
**Step 3: 实现 `nexus-daemon/self_modifier.py`**
|
|
2345
|
+
|
|
2346
|
+
```python
|
|
2347
|
+
# nexus-daemon/self_modifier.py
|
|
2348
|
+
"""Self-Modifier: applies improvement suggestions to the ultrapower repo."""
|
|
2349
|
+
from __future__ import annotations
|
|
2350
|
+
|
|
2351
|
+
import logging
|
|
2352
|
+
from dataclasses import dataclass
|
|
2353
|
+
from pathlib import Path
|
|
2354
|
+
from typing import Any
|
|
2355
|
+
|
|
2356
|
+
logger = logging.getLogger(__name__)
|
|
2357
|
+
|
|
2358
|
+
# Only these path prefixes are allowed for auto-modification
|
|
2359
|
+
ALLOWED_PREFIXES = ('skills/', 'agents/')
|
|
2360
|
+
# Confidence threshold: below this, skip without applying
|
|
2361
|
+
CONFIDENCE_THRESHOLD = 70
|
|
2362
|
+
|
|
2363
|
+
|
|
2364
|
+
@dataclass
|
|
2365
|
+
class ModificationResult:
|
|
2366
|
+
status: str # 'applied' | 'skipped' | 'rejected' | 'error'
|
|
2367
|
+
reason: str
|
|
2368
|
+
improvement_id: str
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
class SelfModifier:
|
|
2372
|
+
"""Applies improvement suggestions to skill/agent Markdown files."""
|
|
2373
|
+
|
|
2374
|
+
def __init__(self, repo_path: Path) -> None:
|
|
2375
|
+
self.repo_path = Path(repo_path)
|
|
2376
|
+
|
|
2377
|
+
def _validate_target(self, target_file: str) -> str | None:
|
|
2378
|
+
"""Return error message if target_file is invalid, else None."""
|
|
2379
|
+
# Reject path traversal
|
|
2380
|
+
try:
|
|
2381
|
+
resolved = (self.repo_path / target_file).resolve()
|
|
2382
|
+
resolved.relative_to(self.repo_path.resolve())
|
|
2383
|
+
except ValueError:
|
|
2384
|
+
return 'path traversal detected'
|
|
2385
|
+
|
|
2386
|
+
# Only allow skills/ and agents/ directories
|
|
2387
|
+
if not any(target_file.startswith(p) for p in ALLOWED_PREFIXES):
|
|
2388
|
+
return f'target outside allowed prefixes {ALLOWED_PREFIXES}'
|
|
2389
|
+
|
|
2390
|
+
# Only allow Markdown files
|
|
2391
|
+
if not target_file.endswith('.md'):
|
|
2392
|
+
return 'only .md files are allowed for auto-modification'
|
|
2393
|
+
|
|
2394
|
+
return None
|
|
2395
|
+
|
|
2396
|
+
def apply(self, improvement: dict[str, Any]) -> ModificationResult:
|
|
2397
|
+
"""Apply a single improvement. Returns ModificationResult."""
|
|
2398
|
+
imp_id = improvement.get('id', 'unknown')
|
|
2399
|
+
confidence = improvement.get('confidence', 0)
|
|
2400
|
+
target_file = improvement.get('targetFile', '')
|
|
2401
|
+
new_content = improvement.get('newContent')
|
|
2402
|
+
|
|
2403
|
+
# Confidence gate
|
|
2404
|
+
if confidence < CONFIDENCE_THRESHOLD:
|
|
2405
|
+
return ModificationResult(
|
|
2406
|
+
status='skipped',
|
|
2407
|
+
reason='confidence below threshold',
|
|
2408
|
+
improvement_id=imp_id,
|
|
2409
|
+
)
|
|
2410
|
+
|
|
2411
|
+
# Path validation
|
|
2412
|
+
error = self._validate_target(target_file)
|
|
2413
|
+
if error:
|
|
2414
|
+
return ModificationResult(
|
|
2415
|
+
status='rejected',
|
|
2416
|
+
reason=error,
|
|
2417
|
+
improvement_id=imp_id,
|
|
2418
|
+
)
|
|
2419
|
+
|
|
2420
|
+
# Apply: write new content
|
|
2421
|
+
target_path = self.repo_path / target_file
|
|
2422
|
+
if new_content is None:
|
|
2423
|
+
return ModificationResult(
|
|
2424
|
+
status='error',
|
|
2425
|
+
reason='newContent field missing',
|
|
2426
|
+
improvement_id=imp_id,
|
|
2427
|
+
)
|
|
2428
|
+
|
|
2429
|
+
try:
|
|
2430
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2431
|
+
target_path.write_text(new_content, encoding='utf-8')
|
|
2432
|
+
logger.info('Applied improvement %s to %s', imp_id, target_file)
|
|
2433
|
+
return ModificationResult(
|
|
2434
|
+
status='applied',
|
|
2435
|
+
reason=f'wrote {len(new_content)} bytes to {target_file}',
|
|
2436
|
+
improvement_id=imp_id,
|
|
2437
|
+
)
|
|
2438
|
+
except OSError as e:
|
|
2439
|
+
return ModificationResult(
|
|
2440
|
+
status='error',
|
|
2441
|
+
reason=str(e),
|
|
2442
|
+
improvement_id=imp_id,
|
|
2443
|
+
)
|
|
2444
|
+
```
|
|
2445
|
+
|
|
2446
|
+
**Step 4: 运行测试确认通过**
|
|
2447
|
+
|
|
2448
|
+
```bash
|
|
2449
|
+
cd nexus-daemon && python -m pytest tests/test_self_modifier.py -v
|
|
2450
|
+
```
|
|
2451
|
+
Expected: PASS (3 tests)
|
|
2452
|
+
|
|
2453
|
+
**Step 5: 集成到 `daemon.py` 的 `run_once`**
|
|
2454
|
+
|
|
2455
|
+
在 `NexusDaemon.__init__` 中添加:
|
|
2456
|
+
|
|
2457
|
+
```python
|
|
2458
|
+
from self_modifier import SelfModifier
|
|
2459
|
+
|
|
2460
|
+
# 在 __init__ 末尾:
|
|
2461
|
+
self._modifier = SelfModifier(repo_path=self.repo_path)
|
|
2462
|
+
```
|
|
2463
|
+
|
|
2464
|
+
在 `run_once` 的改进通知循环中,在 `notify_improvement` 调用之后添加自动应用逻辑:
|
|
2465
|
+
|
|
2466
|
+
```python
|
|
2467
|
+
# Auto-apply high-confidence improvements (confidence >= 80)
|
|
2468
|
+
if imp.get('confidence', 0) >= 80 and imp.get('status') == 'pending':
|
|
2469
|
+
result = self._modifier.apply(imp)
|
|
2470
|
+
if result.status == 'applied':
|
|
2471
|
+
imp['status'] = 'auto_applied'
|
|
2472
|
+
f.write_text(
|
|
2473
|
+
__import__('json').dumps(imp, indent=2, ensure_ascii=False),
|
|
2474
|
+
encoding='utf-8',
|
|
2475
|
+
)
|
|
2476
|
+
logger.info('Auto-applied improvement %s', imp_id)
|
|
2477
|
+
```
|
|
2478
|
+
|
|
2479
|
+
**Step 6: 运行全量测试**
|
|
2480
|
+
|
|
2481
|
+
```bash
|
|
2482
|
+
cd nexus-daemon && python -m pytest tests/ -v
|
|
2483
|
+
```
|
|
2484
|
+
Expected: PASS (all tests)
|
|
2485
|
+
|
|
2486
|
+
**Step 7: Commit**
|
|
2487
|
+
|
|
2488
|
+
```bash
|
|
2489
|
+
git add nexus-daemon/self_modifier.py nexus-daemon/tests/test_self_modifier.py nexus-daemon/daemon.py
|
|
2490
|
+
git commit -m "feat(nexus): add Self-Modifier module for auto-applying skill improvements"
|
|
2491
|
+
```
|
|
2492
|
+
|
|
2493
|
+
---
|
|
2494
|
+
|
|
2495
|
+
## 实现完成检查清单
|
|
2496
|
+
|
|
2497
|
+
### P0 核心功能(Tasks 1-5)
|
|
2498
|
+
- [ ] Task 1: nexus 配置类型和加载器
|
|
2499
|
+
- [ ] Task 2: data-collector hook(收集会话数据)
|
|
2500
|
+
- [ ] Task 3: consciousness-sync hook(SessionEnd 后 git push)
|
|
2501
|
+
- [ ] Task 4: session-end hook 集成
|
|
2502
|
+
- [ ] Task 5: processSessionEnd 注册 nexus hook
|
|
2503
|
+
|
|
2504
|
+
### P1 重要功能(Tasks 6-9, 14)
|
|
2505
|
+
- [ ] Task 6: nexus-daemon Python 基础框架
|
|
2506
|
+
- [ ] Task 7: Evolution Engine MVP(模式检测 + knowledge_base)
|
|
2507
|
+
- [ ] Task 8: improvement-puller hook(拉取改进)
|
|
2508
|
+
- [ ] Task 9: Telegram Bot 通知
|
|
2509
|
+
- [ ] Task 14: Self-Modifier(代码自动修改模块)
|
|
2510
|
+
|
|
2511
|
+
### P2 增强功能(Tasks 10-13)
|
|
2512
|
+
- [ ] Task 10: Consciousness Loop(后台意识循环)
|
|
2513
|
+
- [ ] Task 11: Self-Evaluator(健康报告)
|
|
2514
|
+
- [ ] Task 12: nexus 管理 Skills(nexus-status / nexus-evolve / nexus-review)
|
|
2515
|
+
- [ ] Task 13: systemd 服务文件(VPS 部署)
|
|
2516
|
+
|
|
2517
|
+
### 验收标准(来自设计文档)
|
|
2518
|
+
- [ ] 会话结束后,数据自动推送到 nexus-daemon 仓库
|
|
2519
|
+
- [ ] VPS 守护进程每分钟拉取新事件并处理
|
|
2520
|
+
- [ ] 后台意识循环每 5 分钟运行一次,写入 scratchpad.md
|
|
2521
|
+
- [ ] 同类模式出现 ≥ 3 次后,Evolution Engine 生成改进建议
|
|
2522
|
+
- [ ] 置信度 ≥ 80 的改进自动通过测试后合并
|
|
2523
|
+
- [ ] 置信度 < 80 的改进通过 Telegram 发送确认请求
|
|
2524
|
+
- [ ] 所有代码修改必须通过 `tsc --noEmit && npm test`
|
|
2525
|
+
- [ ] 不破坏 ultrapower 现有任何功能
|
|
2526
|
+
|
|
2527
|
+
### 最终验证命令
|
|
2528
|
+
|
|
2529
|
+
```bash
|
|
2530
|
+
# TypeScript 层
|
|
2531
|
+
npm run build && npm test
|
|
2532
|
+
|
|
2533
|
+
# Python 层
|
|
2534
|
+
cd nexus-daemon && python -m pytest -v
|
|
2535
|
+
|
|
2536
|
+
# 检查所有 skill 文件存在
|
|
2537
|
+
ls skills/nexus/*/SKILL.md
|
|
2538
|
+
```
|