@archznn/crewloop-skills 0.2.0 → 0.4.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/README.md +21 -31
- package/assets/templates/skill-template.md +58 -0
- package/package.json +5 -1
- package/references/conventions.md +144 -0
- package/references/obsidian-mcp-usage.md +190 -0
- package/references/skill-anatomy.md +77 -0
- package/references/workflow.md +64 -0
- package/servers/dashboard/README.md +87 -0
- package/servers/dashboard/bin/crewloop-dashboard.js +5 -0
- package/servers/dashboard/config-examples/codex-hooks.json +14 -0
- package/servers/dashboard/config-examples/kimi-code-config.toml +6 -0
- package/servers/dashboard/config-examples/opencode-plugin/crewloop-dashboard.js +64 -0
- package/servers/dashboard/package.json +46 -0
- package/servers/dashboard/public/app.js +447 -0
- package/servers/dashboard/public/index.html +96 -0
- package/servers/dashboard/public/styles.css +664 -0
- package/servers/dashboard/src/adapters/codex.ts +50 -0
- package/servers/dashboard/src/adapters/kimi.ts +40 -0
- package/servers/dashboard/src/adapters/opencode.ts +36 -0
- package/servers/dashboard/src/adapters/shim.test.ts +74 -0
- package/servers/dashboard/src/adapters/shim.ts +120 -0
- package/servers/dashboard/src/api/event.ts +70 -0
- package/servers/dashboard/src/api/skills.ts +11 -0
- package/servers/dashboard/src/config.ts +66 -0
- package/servers/dashboard/src/filters/sanitize.test.ts +94 -0
- package/servers/dashboard/src/filters/sanitize.ts +78 -0
- package/servers/dashboard/src/index.ts +24 -0
- package/servers/dashboard/src/presenter.test.ts +69 -0
- package/servers/dashboard/src/presenter.ts +56 -0
- package/servers/dashboard/src/server.test.ts +123 -0
- package/servers/dashboard/src/server.ts +191 -0
- package/servers/dashboard/src/skills/infer.test.ts +86 -0
- package/servers/dashboard/src/skills/infer.ts +53 -0
- package/servers/dashboard/src/skills/mapping.ts +26 -0
- package/servers/dashboard/src/skills/registry.ts +60 -0
- package/servers/dashboard/src/state.test.ts +88 -0
- package/servers/dashboard/src/state.ts +115 -0
- package/servers/dashboard/src/types.ts +110 -0
- package/servers/dashboard/tsconfig.json +19 -0
- package/servers/obsidian-mcp/README.md +82 -0
- package/servers/obsidian-mcp/pyproject.toml +32 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/config.py +47 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/embeddings.py +105 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/indexer.py +79 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/store.py +141 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/indexer/sync.py +37 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/detector.py +66 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/learning/note_generator.py +40 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/main.py +4 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/models.py +42 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/privacy/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/privacy/filter.py +68 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/engine.py +50 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/graph_search.py +55 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/text_search.py +37 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/rag/vector_search.py +118 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/server.py +61 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/create.py +43 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/delete.py +16 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/learn.py +42 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/list.py +16 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/read.py +15 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/registry.py +130 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/related.py +20 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/search.py +26 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/sync.py +22 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/tools/update.py +34 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/__init__.py +0 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/parser.py +82 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/repository.py +68 -0
- package/servers/obsidian-mcp/src/obsidian_mcp/vault/writer.py +61 -0
- package/servers/obsidian-mcp/tests/conftest.py +39 -0
- package/servers/obsidian-mcp/tests/test_async_tools.py +87 -0
- package/servers/obsidian-mcp/tests/test_edge_cases.py +59 -0
- package/servers/obsidian-mcp/tests/test_indexer.py +27 -0
- package/servers/obsidian-mcp/tests/test_integration.py +90 -0
- package/servers/obsidian-mcp/tests/test_learning.py +34 -0
- package/servers/obsidian-mcp/tests/test_privacy.py +31 -0
- package/servers/obsidian-mcp/tests/test_privacy_config.py +44 -0
- package/servers/obsidian-mcp/tests/test_rag.py +64 -0
- package/servers/obsidian-mcp/tests/test_read_raw.py +37 -0
- package/servers/obsidian-mcp/tests/test_tfidf_fallback.py +54 -0
- package/servers/obsidian-mcp/tests/test_tools.py +108 -0
- package/servers/obsidian-mcp/tests/test_vault.py +103 -0
- package/servers/obsidian-mcp/tests/test_writer.py +139 -0
- package/skills/accessibility-auditor/SKILL.md +262 -0
- package/skills/accessibility-auditor/references/a11y-checklist.md +66 -0
- package/skills/architect/SKILL.md +1 -1
- package/skills/designer/SKILL.md +1 -1
- package/skills/docs-writer/SKILL.md +1 -1
- package/skills/engineer/SKILL.md +1 -1
- package/skills/maintainer/SKILL.md +22 -22
- package/skills/obsidian-second-brain/SKILL.md +48 -13
- package/skills/orchestrator/SKILL.md +1 -1
- package/skills/product-manager/SKILL.md +22 -22
- package/skills/researcher/SKILL.md +22 -22
- package/skills/reviewer/SKILL.md +1 -1
- package/skills/security-guard/SKILL.md +142 -0
- package/skills/security-guard/references/security-checklist.md +57 -0
- package/skills/shipper/SKILL.md +1 -1
- package/skills/tester/SKILL.md +22 -22
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Session, DashboardState, ClientSession, ClientEvent, ClientWebSocketMessage } from './types';
|
|
2
|
+
|
|
3
|
+
export function presentEvent(event: Session['events'][number]): ClientEvent {
|
|
4
|
+
return {
|
|
5
|
+
id: event.id,
|
|
6
|
+
timestamp: event.timestamp,
|
|
7
|
+
event_type: event.event_type,
|
|
8
|
+
tool: event.tool,
|
|
9
|
+
detail: event.detail,
|
|
10
|
+
status: event.status,
|
|
11
|
+
duration_ms: event.duration_ms,
|
|
12
|
+
skill: event.skill,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function presentSession(session: Session): ClientSession {
|
|
17
|
+
return {
|
|
18
|
+
id: session.id,
|
|
19
|
+
source: session.source,
|
|
20
|
+
skill: session.active_skill,
|
|
21
|
+
activeSkill:
|
|
22
|
+
session.active_skill
|
|
23
|
+
? {
|
|
24
|
+
name: session.active_skill,
|
|
25
|
+
confidence: session.active_confidence || 'unknown',
|
|
26
|
+
}
|
|
27
|
+
: undefined,
|
|
28
|
+
status: session.status,
|
|
29
|
+
events: session.events.map(presentEvent),
|
|
30
|
+
startTime: session.started_at,
|
|
31
|
+
lastActivity: session.last_event_at,
|
|
32
|
+
toolCounts: session.tool_counts,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function presentState(state: DashboardState): ClientSession[] {
|
|
37
|
+
return Object.values(state.sessions).map(presentSession);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSnapshotMessage(sessions: Session[]): ClientWebSocketMessage {
|
|
41
|
+
return {
|
|
42
|
+
type: 'snapshot',
|
|
43
|
+
sessions: sessions.map(presentSession),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createUpdateMessage(
|
|
48
|
+
session: Session,
|
|
49
|
+
activeSessionId?: string
|
|
50
|
+
): ClientWebSocketMessage {
|
|
51
|
+
return {
|
|
52
|
+
type: 'update',
|
|
53
|
+
session: presentSession(session),
|
|
54
|
+
isActive: activeSessionId === session.id,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import { createDashboardServer } from './server';
|
|
6
|
+
import type { ServerConfig } from './types';
|
|
7
|
+
|
|
8
|
+
function httpGetStatus(port: number, path: string): Promise<number> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const req = http.get({ hostname: '127.0.0.1', port, path }, (res) => {
|
|
11
|
+
resolve(res.statusCode || 0);
|
|
12
|
+
});
|
|
13
|
+
req.on('error', reject);
|
|
14
|
+
req.end();
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getFreePort(): Promise<number> {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const server = http.createServer();
|
|
21
|
+
server.listen(0, '127.0.0.1', () => {
|
|
22
|
+
const address = server.address();
|
|
23
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
24
|
+
server.close(() => resolve(port));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeConfig(port: number, packageRoot: string): ServerConfig {
|
|
30
|
+
return {
|
|
31
|
+
port,
|
|
32
|
+
host: '127.0.0.1',
|
|
33
|
+
packageRoot,
|
|
34
|
+
maxEventsPerSession: 100,
|
|
35
|
+
sessionMaxAgeMs: 60000,
|
|
36
|
+
pruneIntervalMs: 60000,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('DashboardServer', () => {
|
|
41
|
+
let server: ReturnType<typeof createDashboardServer>;
|
|
42
|
+
let port: number;
|
|
43
|
+
|
|
44
|
+
before(async () => {
|
|
45
|
+
port = await getFreePort();
|
|
46
|
+
server = createDashboardServer(makeConfig(port, process.cwd()));
|
|
47
|
+
await server.start();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
after(async () => {
|
|
51
|
+
await server.stop();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('serves index.html', async () => {
|
|
55
|
+
const res = await fetch(`http://127.0.0.1:${port}/`);
|
|
56
|
+
assert.equal(res.status, 200);
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
assert.ok(text.includes('<html'));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('accepts POST /event and broadcasts via WebSocket', async () => {
|
|
62
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
63
|
+
const snapshot = await new Promise<unknown>((resolve, reject) => {
|
|
64
|
+
const timer = setTimeout(() => reject(new Error('WebSocket timeout')), 2000);
|
|
65
|
+
ws.once('message', (data) => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
resolve(JSON.parse(data.toString()));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal((snapshot as { type: string }).type, 'snapshot');
|
|
72
|
+
|
|
73
|
+
const updatePromise = new Promise<unknown>((resolve) => {
|
|
74
|
+
ws.on('message', (data) => {
|
|
75
|
+
const message = JSON.parse(data.toString());
|
|
76
|
+
if (message.type === 'update') {
|
|
77
|
+
resolve(message);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const res = await fetch(`http://127.0.0.1:${port}/event`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
id: 'ev-1',
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
source: 'kimi',
|
|
89
|
+
session_id: 'sess-1',
|
|
90
|
+
event_type: 'tool_start',
|
|
91
|
+
tool: 'Read',
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
assert.equal(res.status, 200);
|
|
95
|
+
|
|
96
|
+
const update = await updatePromise;
|
|
97
|
+
assert.equal((update as { session: { id: string } }).session.id, 'sess-1');
|
|
98
|
+
assert.equal((update as { isActive: boolean }).isActive, true);
|
|
99
|
+
|
|
100
|
+
ws.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects events with dangerous fields', async () => {
|
|
104
|
+
const res = await fetch(`http://127.0.0.1:${port}/event`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'Content-Type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
id: 'ev-bad',
|
|
109
|
+
timestamp: Date.now(),
|
|
110
|
+
source: 'kimi',
|
|
111
|
+
session_id: 'sess-bad',
|
|
112
|
+
event_type: 'tool_start',
|
|
113
|
+
command: 'rm -rf /',
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
assert.equal(res.status, 400);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('blocks static file path traversal', async () => {
|
|
120
|
+
const status = await httpGetStatus(port, '/../../package.json');
|
|
121
|
+
assert.equal(status, 403);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
+
import type { ServerConfig, ClientWebSocketMessage } from './types';
|
|
6
|
+
import { StateStore } from './state';
|
|
7
|
+
import { SkillRegistry } from './skills/registry';
|
|
8
|
+
import { SkillInferenceEngine } from './skills/infer';
|
|
9
|
+
import { createEventHandler } from './api/event';
|
|
10
|
+
import { createSkillsHandler } from './api/skills';
|
|
11
|
+
import { createSnapshotMessage } from './presenter';
|
|
12
|
+
|
|
13
|
+
export interface DashboardServer {
|
|
14
|
+
httpServer: http.Server;
|
|
15
|
+
wss: WebSocketServer;
|
|
16
|
+
state: StateStore;
|
|
17
|
+
start: () => Promise<void>;
|
|
18
|
+
stop: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createDashboardServer(config: ServerConfig): DashboardServer {
|
|
22
|
+
const state = new StateStore({
|
|
23
|
+
maxEventsPerSession: config.maxEventsPerSession,
|
|
24
|
+
sessionMaxAgeMs: config.sessionMaxAgeMs,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const registry = new SkillRegistry(config.packageRoot);
|
|
28
|
+
registry.load();
|
|
29
|
+
|
|
30
|
+
const inference = new SkillInferenceEngine(registry.getSkills());
|
|
31
|
+
const clients = new Set<WebSocket>();
|
|
32
|
+
let activeSessionId: string | undefined;
|
|
33
|
+
|
|
34
|
+
function broadcast(message: ClientWebSocketMessage): void {
|
|
35
|
+
const data = JSON.stringify(message);
|
|
36
|
+
for (const client of clients) {
|
|
37
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
38
|
+
client.send(data);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sendSnapshot(client: WebSocket): void {
|
|
44
|
+
const message = createSnapshotMessage(state.getAllSessions());
|
|
45
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
46
|
+
client.send(JSON.stringify(message));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const eventHandler = createEventHandler({
|
|
51
|
+
state,
|
|
52
|
+
inference,
|
|
53
|
+
broadcast,
|
|
54
|
+
getActiveSessionId: () => activeSessionId,
|
|
55
|
+
setActiveSessionId: (id: string) => {
|
|
56
|
+
activeSessionId = id;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const skillsHandler = createSkillsHandler(registry);
|
|
60
|
+
|
|
61
|
+
const httpServer = http.createServer((req, res) => {
|
|
62
|
+
res.setHeader('Content-Type', 'application/json');
|
|
63
|
+
|
|
64
|
+
if (req.method === 'POST' && req.url === '/event') {
|
|
65
|
+
eventHandler(req, res).catch((err) => {
|
|
66
|
+
console.error('Event handler error:', err);
|
|
67
|
+
res.statusCode = 500;
|
|
68
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (req.method === 'GET' && req.url === '/api/skills') {
|
|
74
|
+
skillsHandler(req, res).catch((err) => {
|
|
75
|
+
console.error('Skills handler error:', err);
|
|
76
|
+
res.statusCode = 500;
|
|
77
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
83
|
+
serveStaticFile(res, 'index.html', 'text/html');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (req.method === 'GET') {
|
|
88
|
+
const filePath = req.url?.slice(1) || '';
|
|
89
|
+
const ext = path.extname(filePath);
|
|
90
|
+
const contentType = CONTENT_TYPES[ext] || 'application/octet-stream';
|
|
91
|
+
serveStaticFile(res, filePath, contentType);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
res.statusCode = 404;
|
|
96
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
100
|
+
|
|
101
|
+
wss.on('connection', (client) => {
|
|
102
|
+
clients.add(client);
|
|
103
|
+
sendSnapshot(client);
|
|
104
|
+
|
|
105
|
+
client.on('message', (raw) => {
|
|
106
|
+
try {
|
|
107
|
+
const message = JSON.parse(raw.toString()) as { type?: string };
|
|
108
|
+
if (message.type === 'ping') {
|
|
109
|
+
client.send(JSON.stringify({ type: 'pong' }));
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore malformed client messages.
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
client.on('close', () => {
|
|
117
|
+
clients.delete(client);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const pruneInterval = setInterval(() => {
|
|
122
|
+
state.pruneInactive();
|
|
123
|
+
}, config.pruneIntervalMs);
|
|
124
|
+
|
|
125
|
+
function serveStaticFile(
|
|
126
|
+
res: http.ServerResponse,
|
|
127
|
+
filePath: string,
|
|
128
|
+
contentType: string
|
|
129
|
+
): void {
|
|
130
|
+
const publicDir = path.resolve(__dirname, '..', 'public');
|
|
131
|
+
const fullPath = path.resolve(publicDir, filePath);
|
|
132
|
+
if (!fullPath.startsWith(publicDir + path.sep)) {
|
|
133
|
+
res.statusCode = 403;
|
|
134
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!fs.existsSync(fullPath)) {
|
|
139
|
+
res.statusCode = 404;
|
|
140
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fs.readFile(fullPath, (err, data) => {
|
|
145
|
+
if (err) {
|
|
146
|
+
res.statusCode = 500;
|
|
147
|
+
res.end(JSON.stringify({ error: 'Failed to read file' }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
res.setHeader('Content-Type', contentType);
|
|
151
|
+
res.statusCode = 200;
|
|
152
|
+
res.end(data);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
httpServer,
|
|
158
|
+
wss,
|
|
159
|
+
state,
|
|
160
|
+
start: () =>
|
|
161
|
+
new Promise<void>((resolve, reject) => {
|
|
162
|
+
httpServer.listen(config.port, config.host, () => {
|
|
163
|
+
console.log(`CrewLoop dashboard running at http://${config.host}:${config.port}`);
|
|
164
|
+
resolve();
|
|
165
|
+
});
|
|
166
|
+
httpServer.on('error', reject);
|
|
167
|
+
}),
|
|
168
|
+
stop: () =>
|
|
169
|
+
new Promise<void>((resolve) => {
|
|
170
|
+
clearInterval(pruneInterval);
|
|
171
|
+
for (const client of clients) {
|
|
172
|
+
client.terminate();
|
|
173
|
+
}
|
|
174
|
+
wss.close(() => {
|
|
175
|
+
httpServer.close(() => {
|
|
176
|
+
resolve();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
184
|
+
'.html': 'text/html',
|
|
185
|
+
'.css': 'text/css',
|
|
186
|
+
'.js': 'application/javascript',
|
|
187
|
+
'.json': 'application/json',
|
|
188
|
+
'.svg': 'image/svg+xml',
|
|
189
|
+
'.png': 'image/png',
|
|
190
|
+
'.ico': 'image/x-icon',
|
|
191
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { SkillInferenceEngine } from './infer';
|
|
4
|
+
import type { DashboardEvent, Session } from '../types';
|
|
5
|
+
|
|
6
|
+
function makeSession(overrides: Partial<Session> = {}): Session {
|
|
7
|
+
return {
|
|
8
|
+
id: 'sess-1',
|
|
9
|
+
source: 'kimi',
|
|
10
|
+
events: [],
|
|
11
|
+
tool_counts: {},
|
|
12
|
+
started_at: Date.now(),
|
|
13
|
+
last_event_at: Date.now(),
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeEvent(overrides: Partial<DashboardEvent> = {}): DashboardEvent {
|
|
19
|
+
return {
|
|
20
|
+
id: 'ev-1',
|
|
21
|
+
timestamp: Date.now(),
|
|
22
|
+
source: 'kimi',
|
|
23
|
+
session_id: 'sess-1',
|
|
24
|
+
event_type: 'tool_start',
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const skills = [
|
|
30
|
+
{ name: 'orchestrator', description: '', icon: 'target' },
|
|
31
|
+
{ name: 'architect', description: '', icon: 'blueprint' },
|
|
32
|
+
{ name: 'engineer', description: '', icon: 'wrench' },
|
|
33
|
+
{ name: 'shipper', description: '', icon: 'rocket-launch' },
|
|
34
|
+
{ name: 'researcher', description: '', icon: 'microscope' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
describe('SkillInferenceEngine', () => {
|
|
38
|
+
it('infers explicit skill from skill_change event', () => {
|
|
39
|
+
const engine = new SkillInferenceEngine(skills);
|
|
40
|
+
const event = makeEvent({ event_type: 'skill_change', skill: 'architect' });
|
|
41
|
+
const result = engine.infer(event, makeSession());
|
|
42
|
+
assert.equal(result.skill, 'architect');
|
|
43
|
+
assert.equal(result.confidence, 'explicit');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('infers explicit skill from Skill tool detail', () => {
|
|
47
|
+
const engine = new SkillInferenceEngine(skills);
|
|
48
|
+
const event = makeEvent({ tool: 'Skill', detail: 'engineer' });
|
|
49
|
+
const result = engine.infer(event, makeSession());
|
|
50
|
+
assert.equal(result.skill, 'engineer');
|
|
51
|
+
assert.equal(result.confidence, 'explicit');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('infers researcher from Read tool', () => {
|
|
55
|
+
const engine = new SkillInferenceEngine(skills);
|
|
56
|
+
const event = makeEvent({ tool: 'Read', detail: 'README.md' });
|
|
57
|
+
const result = engine.infer(event, makeSession());
|
|
58
|
+
assert.equal(result.skill, 'researcher');
|
|
59
|
+
assert.equal(result.confidence, 'heuristic');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('infers shipper from git commit command', () => {
|
|
63
|
+
const engine = new SkillInferenceEngine(skills);
|
|
64
|
+
const event = makeEvent({ tool: 'Bash', detail: 'git commit -m "feat: x"' });
|
|
65
|
+
const result = engine.infer(event, makeSession());
|
|
66
|
+
assert.equal(result.skill, 'shipper');
|
|
67
|
+
assert.equal(result.confidence, 'heuristic');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('falls back to session active skill', () => {
|
|
71
|
+
const engine = new SkillInferenceEngine(skills);
|
|
72
|
+
const session = makeSession({ active_skill: 'architect' });
|
|
73
|
+
const event = makeEvent({ tool: 'UnknownTool' });
|
|
74
|
+
const result = engine.infer(event, session);
|
|
75
|
+
assert.equal(result.skill, 'architect');
|
|
76
|
+
assert.equal(result.confidence, 'heuristic');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns unknown when nothing matches', () => {
|
|
80
|
+
const engine = new SkillInferenceEngine(skills);
|
|
81
|
+
const event = makeEvent({ tool: 'UnknownTool' });
|
|
82
|
+
const result = engine.infer(event, makeSession());
|
|
83
|
+
assert.equal(result.skill, undefined);
|
|
84
|
+
assert.equal(result.confidence, 'unknown');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { DashboardEvent, Session, SkillInferenceResult, SkillMeta } from '../types';
|
|
2
|
+
import { inferFromTool, inferFromGitCommand } from './mapping';
|
|
3
|
+
|
|
4
|
+
export class SkillInferenceEngine {
|
|
5
|
+
private skillNames: Set<string>;
|
|
6
|
+
|
|
7
|
+
constructor(skills: SkillMeta[]) {
|
|
8
|
+
this.skillNames = new Set(skills.map((skill) => skill.name));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
infer(event: DashboardEvent, session: Session): SkillInferenceResult {
|
|
12
|
+
if (event.event_type === 'skill_change' && event.skill) {
|
|
13
|
+
return { skill: event.skill, confidence: 'explicit' };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (event.tool === 'Skill' && event.detail) {
|
|
17
|
+
const skill = this.normalizeSkillName(event.detail);
|
|
18
|
+
if (skill) {
|
|
19
|
+
return { skill, confidence: 'explicit' };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (event.tool === 'Bash' && event.detail) {
|
|
24
|
+
const gitSkill = inferFromGitCommand(event.detail);
|
|
25
|
+
if (gitSkill) {
|
|
26
|
+
return { skill: gitSkill, confidence: 'heuristic' };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (event.tool) {
|
|
31
|
+
const mapped = inferFromTool(event.tool);
|
|
32
|
+
if (mapped && this.skillNames.has(mapped)) {
|
|
33
|
+
return { skill: mapped, confidence: 'heuristic' };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (session.active_skill && this.skillNames.has(session.active_skill)) {
|
|
38
|
+
return { skill: session.active_skill, confidence: 'heuristic' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { skill: undefined, confidence: 'unknown' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private normalizeSkillName(raw: string): string | undefined {
|
|
45
|
+
const normalized = raw.toLowerCase().trim();
|
|
46
|
+
for (const name of this.skillNames) {
|
|
47
|
+
if (name.toLowerCase() === normalized) {
|
|
48
|
+
return name;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ToolToSkillMap } from '../types';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_TOOL_TO_SKILL_MAP: ToolToSkillMap = {
|
|
4
|
+
Task: 'orchestrator',
|
|
5
|
+
Agent: 'orchestrator',
|
|
6
|
+
Read: 'researcher',
|
|
7
|
+
Grep: 'researcher',
|
|
8
|
+
Glob: 'researcher',
|
|
9
|
+
WebSearch: 'researcher',
|
|
10
|
+
FetchURL: 'researcher',
|
|
11
|
+
Edit: 'engineer',
|
|
12
|
+
Write: 'engineer',
|
|
13
|
+
Bash: 'engineer',
|
|
14
|
+
Skill: undefined,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function inferFromTool(toolName: string): string | undefined {
|
|
18
|
+
return DEFAULT_TOOL_TO_SKILL_MAP[toolName];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function inferFromGitCommand(command: string): string | undefined {
|
|
22
|
+
if (/\b(git\s+(commit|push|branch|merge|tag|checkout))\b/.test(command)) {
|
|
23
|
+
return 'shipper';
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { SkillMeta } from '../types';
|
|
4
|
+
|
|
5
|
+
const SKILL_ICONS: Record<string, string> = {
|
|
6
|
+
orchestrator: 'target',
|
|
7
|
+
architect: 'blueprint',
|
|
8
|
+
designer: 'palette',
|
|
9
|
+
engineer: 'wrench',
|
|
10
|
+
reviewer: 'magnifying-glass',
|
|
11
|
+
shipper: 'rocket-launch',
|
|
12
|
+
'docs-writer': 'article',
|
|
13
|
+
tester: 'flask',
|
|
14
|
+
'product-manager': 'chart-bar',
|
|
15
|
+
maintainer: 'toolbox',
|
|
16
|
+
researcher: 'microscope',
|
|
17
|
+
'security-guard': 'shield',
|
|
18
|
+
'accessibility-auditor': 'person',
|
|
19
|
+
'obsidian-second-brain': 'brain',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class SkillRegistry {
|
|
23
|
+
private skills: SkillMeta[] = [];
|
|
24
|
+
private packageRoot: string;
|
|
25
|
+
|
|
26
|
+
constructor(packageRoot: string) {
|
|
27
|
+
this.packageRoot = packageRoot;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
load(): SkillMeta[] {
|
|
31
|
+
const skillsDir = path.join(this.packageRoot, 'skills');
|
|
32
|
+
if (fs.existsSync(skillsDir)) {
|
|
33
|
+
this.skills = fs
|
|
34
|
+
.readdirSync(skillsDir, { withFileTypes: true })
|
|
35
|
+
.filter((entry) => entry.isDirectory())
|
|
36
|
+
.map((entry) => ({
|
|
37
|
+
name: entry.name,
|
|
38
|
+
description: '',
|
|
39
|
+
icon: SKILL_ICONS[entry.name] || 'circle',
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return this.skills;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getSkills(): SkillMeta[] {
|
|
47
|
+
if (this.skills.length === 0) {
|
|
48
|
+
this.load();
|
|
49
|
+
}
|
|
50
|
+
return this.skills;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getIcon(skillName: string): string {
|
|
54
|
+
return SKILL_ICONS[skillName] || 'circle';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
hasSkill(skillName: string): boolean {
|
|
58
|
+
return this.getSkills().some((skill) => skill.name === skillName);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { StateStore } from './state';
|
|
4
|
+
import type { DashboardEvent } from './types';
|
|
5
|
+
|
|
6
|
+
function makeEvent(overrides: Partial<DashboardEvent> = {}): DashboardEvent {
|
|
7
|
+
return {
|
|
8
|
+
id: 'ev-1',
|
|
9
|
+
timestamp: Date.now(),
|
|
10
|
+
source: 'kimi',
|
|
11
|
+
session_id: 'sess-1',
|
|
12
|
+
event_type: 'tool_start',
|
|
13
|
+
tool: 'Read',
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('StateStore', () => {
|
|
19
|
+
it('creates session on first event', () => {
|
|
20
|
+
const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
|
|
21
|
+
const session = store.applyEvent(makeEvent());
|
|
22
|
+
assert.equal(session.id, 'sess-1');
|
|
23
|
+
assert.equal(session.source, 'kimi');
|
|
24
|
+
assert.equal(session.events.length, 1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('caps events per session', () => {
|
|
28
|
+
const store = new StateStore({ maxEventsPerSession: 3, sessionMaxAgeMs: 60000 });
|
|
29
|
+
for (let i = 0; i < 5; i++) {
|
|
30
|
+
store.applyEvent(makeEvent({ id: `ev-${i}`, timestamp: Date.now() + i }));
|
|
31
|
+
}
|
|
32
|
+
const session = store.getSession('sess-1')!;
|
|
33
|
+
assert.equal(session.events.length, 3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('counts tool usage', () => {
|
|
37
|
+
const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
|
|
38
|
+
store.applyEvent(makeEvent({ tool: 'Read' }));
|
|
39
|
+
store.applyEvent(makeEvent({ tool: 'Read' }));
|
|
40
|
+
store.applyEvent(makeEvent({ tool: 'Edit' }));
|
|
41
|
+
const session = store.getSession('sess-1')!;
|
|
42
|
+
assert.equal(session.tool_counts['Read'], 2);
|
|
43
|
+
assert.equal(session.tool_counts['Edit'], 1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('sets active skill when event carries skill', () => {
|
|
47
|
+
const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
|
|
48
|
+
store.applyEvent(makeEvent({ skill: 'architect', event_type: 'skill_change' }));
|
|
49
|
+
const session = store.getSession('sess-1')!;
|
|
50
|
+
assert.equal(session.active_skill, 'architect');
|
|
51
|
+
assert.equal(session.active_confidence, 'explicit');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('derives running status from tool_start', () => {
|
|
55
|
+
const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
|
|
56
|
+
store.applyEvent(makeEvent({ event_type: 'tool_start' }));
|
|
57
|
+
assert.equal(store.getSession('sess-1')!.status, 'running');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('derives success status from tool_end', () => {
|
|
61
|
+
const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
|
|
62
|
+
store.applyEvent(makeEvent({ event_type: 'tool_end', status: 'success' }));
|
|
63
|
+
assert.equal(store.getSession('sess-1')!.status, 'success');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('derives error status from failed tool_end', () => {
|
|
67
|
+
const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
|
|
68
|
+
store.applyEvent(makeEvent({ event_type: 'tool_end', status: 'error' }));
|
|
69
|
+
assert.equal(store.getSession('sess-1')!.status, 'error');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('prunes inactive sessions', () => {
|
|
73
|
+
const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 1000 });
|
|
74
|
+
store.applyEvent(makeEvent());
|
|
75
|
+
const removed = store.pruneInactive(Date.now() + 2000);
|
|
76
|
+
assert.equal(removed, 1);
|
|
77
|
+
assert.equal(store.getSession('sess-1'), undefined);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns sessions sorted by last event', () => {
|
|
81
|
+
const store = new StateStore({ maxEventsPerSession: 10, sessionMaxAgeMs: 60000 });
|
|
82
|
+
store.applyEvent(makeEvent({ session_id: 'a', timestamp: 1000 }));
|
|
83
|
+
store.applyEvent(makeEvent({ session_id: 'b', timestamp: 2000 }));
|
|
84
|
+
const sessions = store.getAllSessions();
|
|
85
|
+
assert.equal(sessions[0].id, 'b');
|
|
86
|
+
assert.equal(sessions[1].id, 'a');
|
|
87
|
+
});
|
|
88
|
+
});
|