xingp14-clawlink 0.1.2

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.
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ // ClawLink CLI - Connect to ClawLink Hub from any environment
3
+
4
+ import WebSocket from 'ws';
5
+
6
+ const args = process.argv.slice(2);
7
+ const command = args[0];
8
+
9
+ const HUB_URL = process.env.CLAWLINK_HUB_URL || 'ws://localhost:8080';
10
+ const AGENT_ID = process.env.CLAWLINK_AGENT_ID || 'cli-agent';
11
+ const AUTH_TOKEN = process.env.CLAWLINK_TOKEN || 'change-me';
12
+ const AUTO_JOIN = (process.env.CLAWLINK_AUTO_JOIN || '').split(',').filter(Boolean);
13
+
14
+ function log(msg) {
15
+ console.log(`[ClawLink] ${msg}`);
16
+ }
17
+
18
+ function send(ws, msg) {
19
+ ws.send(JSON.stringify(msg));
20
+ }
21
+
22
+ async function main() {
23
+ log(`Connecting to ${HUB_URL} as ${AGENT_ID}...`);
24
+
25
+ return new Promise((resolve, reject) => {
26
+ const ws = new WebSocket(`${HUB_URL}?agentId=${AGENT_ID}&token=${AUTH_TOKEN}`);
27
+
28
+ ws.on('open', () => {
29
+ log('Connected!');
30
+
31
+ // Auto-join topics
32
+ for (const topic of AUTO_JOIN) {
33
+ log(`Auto-joining ${topic}...`);
34
+ send(ws, { type: 'join', topic });
35
+ }
36
+
37
+ // Handle commands
38
+ if (command === 'join') {
39
+ const topic = args[1];
40
+ if (!topic) {
41
+ log('Usage: clawlink join <topic>');
42
+ ws.close();
43
+ resolve();
44
+ return;
45
+ }
46
+ log(`Joining topic: ${topic}`);
47
+ send(ws, { type: 'join', topic });
48
+ setTimeout(() => { ws.close(); resolve(); }, 1000);
49
+ }
50
+ else if (command === 'leave') {
51
+ const topic = args[1];
52
+ if (!topic) {
53
+ log('Usage: clawlink leave <topic>');
54
+ ws.close();
55
+ resolve();
56
+ return;
57
+ }
58
+ log(`Leaving topic: ${topic}`);
59
+ send(ws, { type: 'leave', topic });
60
+ setTimeout(() => { ws.close(); resolve(); }, 1000);
61
+ }
62
+ else if (command === 'send') {
63
+ const topic = args[1];
64
+ const content = args.slice(2).join(' ');
65
+ if (!topic || !content) {
66
+ log('Usage: clawlink send <topic> <message>');
67
+ ws.close();
68
+ resolve();
69
+ return;
70
+ }
71
+ log(`Sending to ${topic}: ${content}`);
72
+ send(ws, { type: 'message', topic, content });
73
+ setTimeout(() => { ws.close(); resolve(); }, 1000);
74
+ }
75
+ else if (command === 'list') {
76
+ send(ws, { type: 'topics_list' });
77
+ setTimeout(() => { ws.close(); resolve(); }, 2000);
78
+ }
79
+ else if (command === 'memory-write') {
80
+ const key = args[1];
81
+ const value = args.slice(2).join(' ');
82
+ if (!key || !value) {
83
+ log('Usage: clawlink memory-write <key> <value>');
84
+ ws.close();
85
+ resolve();
86
+ return;
87
+ }
88
+ log(`Writing memory: ${key} = ${value}`);
89
+ send(ws, { type: 'memory_write', key, value });
90
+ setTimeout(() => { ws.close(); resolve(); }, 1000);
91
+ }
92
+ else if (command === 'memory-read') {
93
+ const key = args[1];
94
+ if (!key) {
95
+ log('Usage: clawlink memory-read <key>');
96
+ ws.close();
97
+ resolve();
98
+ return;
99
+ }
100
+ log(`Reading memory: ${key}`);
101
+ send(ws, { type: 'memory_read', key });
102
+ setTimeout(() => { ws.close(); resolve(); }, 2000);
103
+ }
104
+ else if (command === 'shell') {
105
+ // Interactive shell mode
106
+ log('Entering shell mode. Commands: join, leave, send, list, memory-write, memory-read, exit');
107
+ process.stdin.setEncoding('utf8');
108
+ process.stdin.on('data', (line) => {
109
+ const cmd = line.trim().split(' ');
110
+ switch (cmd[0]) {
111
+ case 'join':
112
+ send(ws, { type: 'join', topic: cmd[1] });
113
+ break;
114
+ case 'leave':
115
+ send(ws, { type: 'leave', topic: cmd[1] });
116
+ break;
117
+ case 'send':
118
+ send(ws, { type: 'message', topic: cmd[1], content: cmd.slice(2).join(' ') });
119
+ break;
120
+ case 'list':
121
+ send(ws, { type: 'topics_list' });
122
+ break;
123
+ case 'memory-write':
124
+ send(ws, { type: 'memory_write', key: cmd[1], value: cmd.slice(2).join(' ') });
125
+ break;
126
+ case 'memory-read':
127
+ send(ws, { type: 'memory_read', key: cmd[1] });
128
+ break;
129
+ case 'exit':
130
+ ws.close();
131
+ resolve();
132
+ break;
133
+ }
134
+ });
135
+ }
136
+ else if (command) {
137
+ log(`Unknown command: ${command}`);
138
+ log('Commands: join, leave, send, list, memory-write, memory-read, shell');
139
+ ws.close();
140
+ resolve();
141
+ }
142
+ else {
143
+ // Interactive mode - listen for messages
144
+ log('Connected. Waiting for messages...');
145
+ log('Commands: join, leave, send, list, memory-write, memory-read, exit');
146
+ }
147
+ });
148
+
149
+ ws.on('message', (data) => {
150
+ const msg = JSON.parse(data);
151
+ switch (msg.type) {
152
+ case 'welcome':
153
+ log(`Welcome! Agent ID: ${msg.agentId}`);
154
+ break;
155
+ case 'message':
156
+ log(`[${msg.topic}] ${msg.from}: ${msg.content}`);
157
+ break;
158
+ case 'join':
159
+ log(`[${msg.topic}] ${msg.agent} joined`);
160
+ break;
161
+ case 'leave':
162
+ log(`[${msg.topic}] ${msg.agent} left`);
163
+ break;
164
+ case 'topics_list':
165
+ log(`Topics: ${JSON.stringify(msg.topics)}`);
166
+ break;
167
+ case 'topic_members':
168
+ log(`Members in ${msg.topic}: ${JSON.stringify(msg.agents)}`);
169
+ break;
170
+ case 'memory_update':
171
+ log(`Memory updated: ${msg.key} = ${msg.value} (by ${msg.from})`);
172
+ break;
173
+ case 'memory_value':
174
+ log(`Memory ${msg.key}: ${msg.value} (exists: ${msg.exists})`);
175
+ break;
176
+ case 'error':
177
+ log(`Error: ${msg.code} - ${msg.message}`);
178
+ break;
179
+ }
180
+ });
181
+
182
+ ws.on('error', (err) => {
183
+ log(`Error: ${err.message}`);
184
+ reject(err);
185
+ });
186
+
187
+ ws.on('close', () => {
188
+ log('Disconnected');
189
+ });
190
+ });
191
+ }
192
+
193
+ main().catch(console.error);
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ // ClawLink CLI - Connect to ClawLink Hub from any environment
3
+
4
+ import WebSocket from 'ws';
5
+
6
+ const args = process.argv.slice(2);
7
+ const command = args[0];
8
+
9
+ const HUB_URL = process.env.CLAWLINK_HUB_URL || 'ws://localhost:8080';
10
+ const AGENT_ID = process.env.CLAWLINK_AGENT_ID || 'cli-agent';
11
+ const AUTH_TOKEN = process.env.CLAWLINK_TOKEN || 'change-me';
12
+ const AUTO_JOIN = (process.env.CLAWLINK_AUTO_JOIN || '').split(',').filter(Boolean);
13
+
14
+ function log(msg) {
15
+ console.log(`[ClawLink] ${msg}`);
16
+ }
17
+
18
+ function send(ws, msg) {
19
+ ws.send(JSON.stringify(msg));
20
+ }
21
+
22
+ async function main() {
23
+ log(`Connecting to ${HUB_URL} as ${AGENT_ID}...`);
24
+
25
+ return new Promise((resolve, reject) => {
26
+ const ws = new WebSocket(`${HUB_URL}?agentId=${AGENT_ID}&token=${AUTH_TOKEN}`);
27
+
28
+ ws.on('open', () => {
29
+ log('Connected!');
30
+
31
+ // Auto-join topics
32
+ for (const topic of AUTO_JOIN) {
33
+ log(`Auto-joining ${topic}...`);
34
+ send(ws, { type: 'join', topic });
35
+ }
36
+
37
+ // Handle commands
38
+ if (command === 'join') {
39
+ const topic = args[1];
40
+ if (!topic) {
41
+ log('Usage: clawlink join <topic>');
42
+ ws.close();
43
+ resolve();
44
+ return;
45
+ }
46
+ log(`Joining topic: ${topic}`);
47
+ send(ws, { type: 'join', topic });
48
+ setTimeout(() => { ws.close(); resolve(); }, 1000);
49
+ }
50
+ else if (command === 'leave') {
51
+ const topic = args[1];
52
+ if (!topic) {
53
+ log('Usage: clawlink leave <topic>');
54
+ ws.close();
55
+ resolve();
56
+ return;
57
+ }
58
+ log(`Leaving topic: ${topic}`);
59
+ send(ws, { type: 'leave', topic });
60
+ setTimeout(() => { ws.close(); resolve(); }, 1000);
61
+ }
62
+ else if (command === 'send') {
63
+ const topic = args[1];
64
+ const content = args.slice(2).join(' ');
65
+ if (!topic || !content) {
66
+ log('Usage: clawlink send <topic> <message>');
67
+ ws.close();
68
+ resolve();
69
+ return;
70
+ }
71
+ log(`Sending to ${topic}: ${content}`);
72
+ send(ws, { type: 'message', topic, content });
73
+ setTimeout(() => { ws.close(); resolve(); }, 1000);
74
+ }
75
+ else if (command === 'list') {
76
+ send(ws, { type: 'topics_list' });
77
+ setTimeout(() => { ws.close(); resolve(); }, 2000);
78
+ }
79
+ else if (command === 'memory-write') {
80
+ const key = args[1];
81
+ const value = args.slice(2).join(' ');
82
+ if (!key || !value) {
83
+ log('Usage: clawlink memory-write <key> <value>');
84
+ ws.close();
85
+ resolve();
86
+ return;
87
+ }
88
+ log(`Writing memory: ${key} = ${value}`);
89
+ send(ws, { type: 'memory_write', key, value });
90
+ setTimeout(() => { ws.close(); resolve(); }, 1000);
91
+ }
92
+ else if (command === 'memory-read') {
93
+ const key = args[1];
94
+ if (!key) {
95
+ log('Usage: clawlink memory-read <key>');
96
+ ws.close();
97
+ resolve();
98
+ return;
99
+ }
100
+ log(`Reading memory: ${key}`);
101
+ send(ws, { type: 'memory_read', key });
102
+ setTimeout(() => { ws.close(); resolve(); }, 2000);
103
+ }
104
+ else if (command === 'shell') {
105
+ // Interactive shell mode
106
+ log('Entering shell mode. Commands: join, leave, send, list, memory-write, memory-read, exit');
107
+ process.stdin.setEncoding('utf8');
108
+ process.stdin.on('data', (line) => {
109
+ const cmd = line.trim().split(' ');
110
+ switch (cmd[0]) {
111
+ case 'join':
112
+ send(ws, { type: 'join', topic: cmd[1] });
113
+ break;
114
+ case 'leave':
115
+ send(ws, { type: 'leave', topic: cmd[1] });
116
+ break;
117
+ case 'send':
118
+ send(ws, { type: 'message', topic: cmd[1], content: cmd.slice(2).join(' ') });
119
+ break;
120
+ case 'list':
121
+ send(ws, { type: 'topics_list' });
122
+ break;
123
+ case 'memory-write':
124
+ send(ws, { type: 'memory_write', key: cmd[1], value: cmd.slice(2).join(' ') });
125
+ break;
126
+ case 'memory-read':
127
+ send(ws, { type: 'memory_read', key: cmd[1] });
128
+ break;
129
+ case 'exit':
130
+ ws.close();
131
+ resolve();
132
+ break;
133
+ }
134
+ });
135
+ }
136
+ else if (command) {
137
+ log(`Unknown command: ${command}`);
138
+ log('Commands: join, leave, send, list, memory-write, memory-read, shell');
139
+ ws.close();
140
+ resolve();
141
+ }
142
+ else {
143
+ // Interactive mode - listen for messages
144
+ log('Connected. Waiting for messages...');
145
+ log('Commands: join, leave, send, list, memory-write, memory-read, exit');
146
+ }
147
+ });
148
+
149
+ ws.on('message', (data) => {
150
+ const msg = JSON.parse(data);
151
+ switch (msg.type) {
152
+ case 'welcome':
153
+ log(`Welcome! Agent ID: ${msg.agentId}`);
154
+ break;
155
+ case 'message':
156
+ log(`[${msg.topic}] ${msg.from}: ${msg.content}`);
157
+ break;
158
+ case 'join':
159
+ log(`[${msg.topic}] ${msg.agent} joined`);
160
+ break;
161
+ case 'leave':
162
+ log(`[${msg.topic}] ${msg.agent} left`);
163
+ break;
164
+ case 'topics_list':
165
+ log(`Topics: ${JSON.stringify(msg.topics)}`);
166
+ break;
167
+ case 'topic_members':
168
+ log(`Members in ${msg.topic}: ${JSON.stringify(msg.agents)}`);
169
+ break;
170
+ case 'memory_update':
171
+ log(`Memory updated: ${msg.key} = ${msg.value} (by ${msg.from})`);
172
+ break;
173
+ case 'memory_value':
174
+ log(`Memory ${msg.key}: ${msg.value} (exists: ${msg.exists})`);
175
+ break;
176
+ case 'error':
177
+ log(`Error: ${msg.code} - ${msg.message}`);
178
+ break;
179
+ }
180
+ });
181
+
182
+ ws.on('error', (err) => {
183
+ log(`Error: ${err.message}`);
184
+ reject(err);
185
+ });
186
+
187
+ ws.on('close', () => {
188
+ log('Disconnected');
189
+ });
190
+ });
191
+ }
192
+
193
+ main().catch(console.error);
package/dist/index.js ADDED
@@ -0,0 +1,231 @@
1
+ // ClawLink Plugin for OpenClaw
2
+ // Enables OpenClaw agents to connect to ClawLink hub
3
+
4
+ import { WebSocket } from 'ws';
5
+
6
+ class ClawLinkChannel {
7
+ constructor() {
8
+ this.name = 'clawlink';
9
+ this.ws = null;
10
+ this.config = null;
11
+ this.ctx = null;
12
+ this.reconnectTimer = null;
13
+ this.pingTimer = null;
14
+ this.pendingMessages = new Map();
15
+ this.messageHandlers = [];
16
+ this.topics = new Set();
17
+ this.agentId = '';
18
+ }
19
+
20
+ async initialize(ctx) {
21
+ this.ctx = ctx;
22
+ const config = ctx.config;
23
+ this.config = config;
24
+ this.agentId = config.agentId;
25
+
26
+ if (config.autoJoin) {
27
+ for (const topic of config.autoJoin) {
28
+ this.topics.add(topic);
29
+ }
30
+ }
31
+ if (config.topics) {
32
+ for (const topic of config.topics) {
33
+ this.topics.add(topic);
34
+ }
35
+ }
36
+
37
+ this.connect();
38
+ }
39
+
40
+ connect() {
41
+ if (!this.config || !this.ctx) return;
42
+
43
+ const url = `${this.config.hubUrl}?agentId=${encodeURIComponent(this.config.agentId)}&token=${encodeURIComponent(this.config.token)}`;
44
+
45
+ try {
46
+ this.ws = new WebSocket(url);
47
+
48
+ this.ws.onopen = () => {
49
+ this.ctx?.logger?.info(`[ClawLink] Connected to hub: ${this.config?.hubUrl}`);
50
+
51
+ for (const topic of this.topics) {
52
+ this.send({ type: 'join', topic });
53
+ }
54
+
55
+ this.pingTimer = setInterval(() => {
56
+ if (this.ws?.readyState === WebSocket.OPEN) {
57
+ this.send({ type: 'ping' });
58
+ }
59
+ }, 25000);
60
+ };
61
+
62
+ this.ws.onmessage = (event) => {
63
+ try {
64
+ const msg = JSON.parse(event.data);
65
+ this.handleMessage(msg);
66
+ } catch (e) {
67
+ this.ctx?.logger?.error('[ClawLink] Failed to parse message:', e);
68
+ }
69
+ };
70
+
71
+ this.ws.onclose = (event) => {
72
+ this.ctx?.logger?.warn(`[ClawLink] Disconnected (code: ${event.code})`);
73
+ this.scheduleReconnect();
74
+ };
75
+
76
+ this.ws.onerror = (error) => {
77
+ this.ctx?.logger?.error('[ClawLink] WebSocket error:', error);
78
+ };
79
+ } catch (e) {
80
+ this.ctx?.logger?.error('[ClawLink] Failed to connect:', e);
81
+ this.scheduleReconnect();
82
+ }
83
+ }
84
+
85
+ scheduleReconnect() {
86
+ if (this.reconnectTimer) return;
87
+
88
+ this.reconnectTimer = setTimeout(() => {
89
+ this.reconnectTimer = null;
90
+ this.ctx?.logger?.info('[ClawLink] Attempting to reconnect...');
91
+ this.connect();
92
+ }, 5000);
93
+ }
94
+
95
+ handleMessage(msg) {
96
+ switch (msg.type) {
97
+ case 'welcome':
98
+ this.ctx?.logger?.info(`[ClawLink] Authenticated as ${msg.agentId}`);
99
+ break;
100
+
101
+ case 'message':
102
+ if (msg.from !== this.agentId) {
103
+ this.ctx?.dispatch({
104
+ channel: 'clawlink',
105
+ id: msg.id,
106
+ from: msg.from,
107
+ text: msg.content,
108
+ topic: msg.topic,
109
+ timestamp: msg.timestamp,
110
+ });
111
+ }
112
+ break;
113
+
114
+ case 'join':
115
+ this.ctx?.logger?.debug(`[ClawLink] Agent ${msg.agent} joined ${msg.topic}`);
116
+ break;
117
+
118
+ case 'leave':
119
+ this.ctx?.logger?.debug(`[ClawLink] Agent ${msg.agent} left ${msg.topic}`);
120
+ break;
121
+
122
+ case 'history':
123
+ this.ctx?.logger?.debug(`[ClawLink] Received ${msg.messages?.length || 0} historical messages for ${msg.topic}`);
124
+ for (const historicalMsg of (msg.messages || [])) {
125
+ if (historicalMsg.from !== this.agentId) {
126
+ this.ctx?.dispatch({
127
+ channel: 'clawlink',
128
+ id: historicalMsg.id,
129
+ from: historicalMsg.from,
130
+ text: historicalMsg.content,
131
+ topic: historicalMsg.topic,
132
+ timestamp: historicalMsg.timestamp,
133
+ isHistorical: true,
134
+ });
135
+ }
136
+ }
137
+ break;
138
+
139
+ case 'pong':
140
+ break;
141
+
142
+ case 'memory_update':
143
+ this.ctx?.logger?.debug(`[ClawLink] Memory updated: ${msg.key} by ${msg.from}`);
144
+ break;
145
+
146
+ case 'error':
147
+ this.ctx?.logger?.error(`[ClawLink] Server error: ${msg.code} - ${msg.message}`);
148
+ break;
149
+
150
+ case 'memory_result':
151
+ if (msg.mid && this.pendingMessages.has(msg.mid)) {
152
+ const resolve = this.pendingMessages.get(msg.mid);
153
+ this.pendingMessages.delete(msg.mid);
154
+ resolve(msg.value ?? null);
155
+ }
156
+ break;
157
+ }
158
+
159
+ for (const handler of this.messageHandlers) {
160
+ try {
161
+ handler(msg);
162
+ } catch (e) {
163
+ this.ctx?.logger?.error('[ClawLink] Handler error:', e);
164
+ }
165
+ }
166
+ }
167
+
168
+ send(msg) {
169
+ if (this.ws?.readyState === WebSocket.OPEN) {
170
+ try {
171
+ this.ws.send(JSON.stringify(msg));
172
+ } catch (e) {
173
+ this.ctx?.logger?.error('[ClawLink] Failed to send message:', e);
174
+ }
175
+ } else {
176
+ this.ctx?.logger?.warn('[ClawLink] Cannot send: WebSocket not connected');
177
+ }
178
+ }
179
+
180
+ async sendMessage(topic, content) {
181
+ this.send({ type: 'message', topic, content });
182
+ }
183
+
184
+ async joinTopic(topic) {
185
+ this.topics.add(topic);
186
+ this.send({ type: 'join', topic });
187
+ }
188
+
189
+ async leaveTopic(topic) {
190
+ this.topics.delete(topic);
191
+ this.send({ type: 'leave', topic });
192
+ }
193
+
194
+ async writeMemory(key, value) {
195
+ this.send({ type: 'memory_write', key, value });
196
+ }
197
+
198
+ async readMemory(key) {
199
+ return new Promise((resolve) => {
200
+ const mid = Date.now().toString();
201
+ this.pendingMessages.set(mid, resolve);
202
+ this.send({ type: 'memory_read', key, mid });
203
+
204
+ setTimeout(() => {
205
+ if (this.pendingMessages.has(mid)) {
206
+ this.pendingMessages.delete(mid);
207
+ resolve(null);
208
+ }
209
+ }, 5000);
210
+ });
211
+ }
212
+
213
+ onMessage(handler) {
214
+ this.messageHandlers.push(handler);
215
+ }
216
+
217
+ async shutdown() {
218
+ if (this.pingTimer) clearInterval(this.pingTimer);
219
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
220
+
221
+ for (const topic of this.topics) {
222
+ this.send({ type: 'leave', topic });
223
+ }
224
+
225
+ if (this.ws) {
226
+ this.ws.close(1000, 'Agent shutting down');
227
+ }
228
+ }
229
+ }
230
+
231
+ export default new ClawLinkChannel();
@@ -0,0 +1,61 @@
1
+ {
2
+ "id": "clawlink",
3
+ "name": "ClawLink",
4
+ "description": "Connect to ClawLink Hub for topic-based multi-agent communication and shared memory",
5
+ "version": "0.1.0",
6
+ "channels": ["clawlink"],
7
+ "skills": ["./skills/clawlink"],
8
+ "configSchema": {
9
+ "type": "object",
10
+ "additionalProperties": false,
11
+ "properties": {
12
+ "enabled": {
13
+ "type": "boolean",
14
+ "default": true,
15
+ "description": "Enable the ClawLink channel"
16
+ },
17
+ "hubUrl": {
18
+ "type": "string",
19
+ "description": "WebSocket URL of the ClawLink Hub (e.g., ws://vm153:8080)",
20
+ "default": "ws://localhost:8080"
21
+ },
22
+ "agentId": {
23
+ "type": "string",
24
+ "description": "Unique agent identifier for this OpenClaw instance"
25
+ },
26
+ "token": {
27
+ "type": "string",
28
+ "description": "Authentication token for the ClawLink Hub"
29
+ },
30
+ "autoJoin": {
31
+ "type": "array",
32
+ "items": { "type": "string" },
33
+ "description": "Topics to automatically join on startup"
34
+ },
35
+ "topics": {
36
+ "type": "array",
37
+ "items": { "type": "string" },
38
+ "description": "Alias for autoJoin (topics to subscribe to)"
39
+ }
40
+ },
41
+ "required": ["hubUrl", "agentId", "token"]
42
+ },
43
+ "uiHints": {
44
+ "hubUrl": {
45
+ "label": "Hub URL",
46
+ "placeholder": "ws://vm153:8080",
47
+ "help": "WebSocket endpoint of your ClawLink Hub"
48
+ },
49
+ "agentId": {
50
+ "label": "Agent ID",
51
+ "placeholder": "my-agent",
52
+ "help": "Unique name that identifies this agent in topics"
53
+ },
54
+ "token": {
55
+ "label": "Hub Token",
56
+ "placeholder": "ClawLink2026",
57
+ "sensitive": true,
58
+ "help": "Authentication token (get from Hub administrator)"
59
+ }
60
+ }
61
+ }