opencode-pilot 0.1.0 → 0.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/.github/workflows/ci.yml +8 -1
- package/.releaserc.cjs +10 -1
- package/AGENTS.md +6 -12
- package/README.md +31 -25
- package/bin/opencode-pilot +47 -209
- package/examples/config.yaml +2 -9
- package/package.json +6 -6
- package/plugin/index.js +45 -245
- package/service/{io.opencode.ntfy.plist → io.opencode.pilot.plist} +5 -5
- package/service/server.js +44 -1381
- package/test/run_tests.bash +1 -1
- package/test/test_actions.bash +21 -36
- package/test/test_cli.bash +20 -24
- package/test/test_plist.bash +11 -12
- package/test/test_poller.bash +20 -20
- package/test/test_repo_config.bash +19 -233
- package/test/test_service.bash +48 -1095
- package/test/unit/paths.test.js +16 -43
- package/test/unit/plugin.test.js +46 -0
- package/dist/opencode-ntfy.tar.gz +0 -0
- package/plugin/config.js +0 -76
- package/plugin/logger.js +0 -125
- package/plugin/notifier.js +0 -110
- package/test/test_config.bash +0 -438
- package/test/test_logger.bash +0 -401
- package/test/test_notifier.bash +0 -310
- package/test/test_plugin.bash +0 -952
- package/test/unit/config.test.js +0 -86
package/test/unit/paths.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for consistent path naming across the codebase.
|
|
3
3
|
*
|
|
4
|
-
* These tests ensure all config
|
|
4
|
+
* These tests ensure all config paths use "opencode-pilot"
|
|
5
5
|
* and not the old "opencode-ntfy" name.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -14,64 +14,37 @@ import { fileURLToPath } from 'url';
|
|
|
14
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
15
|
const __dirname = dirname(__filename);
|
|
16
16
|
const ROOT_DIR = join(__dirname, '..', '..');
|
|
17
|
-
const PLUGIN_DIR = join(ROOT_DIR, 'plugin');
|
|
18
17
|
const SERVICE_DIR = join(ROOT_DIR, 'service');
|
|
19
18
|
|
|
20
19
|
describe('Path naming consistency', () => {
|
|
21
20
|
|
|
22
|
-
describe('
|
|
23
|
-
const
|
|
24
|
-
const content = readFileSync(
|
|
21
|
+
describe('server.js', () => {
|
|
22
|
+
const serverPath = join(SERVICE_DIR, 'server.js');
|
|
23
|
+
const content = readFileSync(serverPath, 'utf8');
|
|
25
24
|
|
|
26
25
|
test('uses opencode-pilot config path', () => {
|
|
27
|
-
assert.match(content, /opencode-pilot.*config\.yaml/,
|
|
28
|
-
'
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test('does not reference old opencode-ntfy path', () => {
|
|
32
|
-
assert.doesNotMatch(content, /opencode-ntfy/,
|
|
33
|
-
'config.js should not reference old opencode-ntfy name');
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
describe('logger.js', () => {
|
|
38
|
-
const loggerPath = join(PLUGIN_DIR, 'logger.js');
|
|
39
|
-
const content = readFileSync(loggerPath, 'utf8');
|
|
40
|
-
|
|
41
|
-
test('uses opencode-pilot debug log path', () => {
|
|
42
|
-
assert.match(content, /opencode-pilot.*debug\.log/,
|
|
43
|
-
'logger.js should reference opencode-pilot debug log path');
|
|
26
|
+
assert.match(content, /opencode-pilot.*config\.yaml|opencode-pilot/,
|
|
27
|
+
'server.js should reference opencode-pilot paths');
|
|
44
28
|
});
|
|
45
29
|
|
|
46
|
-
test('does not reference old opencode-ntfy
|
|
30
|
+
test('does not reference old opencode-ntfy name', () => {
|
|
47
31
|
assert.doesNotMatch(content, /opencode-ntfy/,
|
|
48
|
-
'
|
|
32
|
+
'server.js should not reference old opencode-ntfy name');
|
|
49
33
|
});
|
|
50
34
|
});
|
|
51
35
|
|
|
52
|
-
describe('
|
|
53
|
-
const
|
|
54
|
-
const content = readFileSync(
|
|
55
|
-
|
|
56
|
-
test('uses opencode-pilot socket path', () => {
|
|
57
|
-
assert.match(content, /opencode-pilot\.sock/,
|
|
58
|
-
'server.js should reference opencode-pilot socket path');
|
|
59
|
-
});
|
|
36
|
+
describe('plist file', () => {
|
|
37
|
+
const plistPath = join(SERVICE_DIR, 'io.opencode.pilot.plist');
|
|
38
|
+
const content = readFileSync(plistPath, 'utf8');
|
|
60
39
|
|
|
61
|
-
test('uses opencode-pilot
|
|
62
|
-
assert.match(content, /opencode
|
|
63
|
-
'
|
|
40
|
+
test('uses opencode-pilot label', () => {
|
|
41
|
+
assert.match(content, /io\.opencode\.pilot/,
|
|
42
|
+
'plist should use io.opencode.pilot label');
|
|
64
43
|
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe('index.js (plugin entry)', () => {
|
|
68
|
-
const indexPath = join(PLUGIN_DIR, 'index.js');
|
|
69
|
-
const content = readFileSync(indexPath, 'utf8');
|
|
70
44
|
|
|
71
|
-
test('does not reference old opencode-ntfy name
|
|
72
|
-
// Allow "ntfy" alone (the service name) but not "opencode-ntfy"
|
|
45
|
+
test('does not reference old opencode-ntfy name', () => {
|
|
73
46
|
assert.doesNotMatch(content, /opencode-ntfy/,
|
|
74
|
-
'
|
|
47
|
+
'plist should not reference old opencode-ntfy name');
|
|
75
48
|
});
|
|
76
49
|
});
|
|
77
50
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for plugin/index.js - Auto-start plugin for OpenCode
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, mock } from "node:test";
|
|
6
|
+
import assert from "node:assert";
|
|
7
|
+
|
|
8
|
+
describe("plugin/index.js", () => {
|
|
9
|
+
describe("exports", () => {
|
|
10
|
+
it("exports PilotPlugin as named export", async () => {
|
|
11
|
+
const plugin = await import("../../plugin/index.js");
|
|
12
|
+
assert.strictEqual(typeof plugin.PilotPlugin, "function");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("exports PilotPlugin as default export", async () => {
|
|
16
|
+
const plugin = await import("../../plugin/index.js");
|
|
17
|
+
assert.strictEqual(plugin.default, plugin.PilotPlugin);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("PilotPlugin", () => {
|
|
22
|
+
it("returns empty hooks object", async () => {
|
|
23
|
+
const plugin = await import("../../plugin/index.js");
|
|
24
|
+
|
|
25
|
+
// Mock context with $ shell function
|
|
26
|
+
const mockShell = mock.fn(() => Promise.resolve());
|
|
27
|
+
mockShell.quiet = mock.fn(() => Promise.resolve());
|
|
28
|
+
const ctx = { $: mockShell };
|
|
29
|
+
|
|
30
|
+
// Call plugin - it will try to fetch health endpoint which will fail
|
|
31
|
+
// in test environment, so it will try to start daemon
|
|
32
|
+
const hooks = await plugin.PilotPlugin(ctx);
|
|
33
|
+
|
|
34
|
+
// Should return empty hooks object (no event handlers)
|
|
35
|
+
assert.deepStrictEqual(hooks, {});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is an async function", async () => {
|
|
39
|
+
const plugin = await import("../../plugin/index.js");
|
|
40
|
+
assert.strictEqual(
|
|
41
|
+
plugin.PilotPlugin.constructor.name,
|
|
42
|
+
"AsyncFunction"
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
Binary file
|
package/plugin/config.js
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
// Configuration management for opencode-pilot
|
|
2
|
-
// Reads from ~/.config/opencode-pilot/config.yaml
|
|
3
|
-
//
|
|
4
|
-
// Example config file (~/.config/opencode-pilot/config.yaml):
|
|
5
|
-
// notifications:
|
|
6
|
-
// topic: my-secret-topic
|
|
7
|
-
// server: https://ntfy.sh
|
|
8
|
-
// idle_delay_ms: 300000
|
|
9
|
-
// debug: true
|
|
10
|
-
|
|
11
|
-
import { readFileSync, existsSync } from 'fs'
|
|
12
|
-
import { join } from 'path'
|
|
13
|
-
import { homedir } from 'os'
|
|
14
|
-
import YAML from 'yaml'
|
|
15
|
-
|
|
16
|
-
const DEFAULT_CONFIG_PATH = join(homedir(), '.config', 'opencode-pilot', 'config.yaml')
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Load configuration from config file
|
|
20
|
-
* @param {string} [configPath] - Optional path to config file (for testing)
|
|
21
|
-
*/
|
|
22
|
-
export function loadConfig(configPath) {
|
|
23
|
-
const actualPath = configPath || DEFAULT_CONFIG_PATH
|
|
24
|
-
|
|
25
|
-
// Load config.yaml if it exists
|
|
26
|
-
let fileConfig = {}
|
|
27
|
-
if (existsSync(actualPath)) {
|
|
28
|
-
try {
|
|
29
|
-
const content = readFileSync(actualPath, 'utf8')
|
|
30
|
-
const parsed = YAML.parse(content)
|
|
31
|
-
// Extract notifications section
|
|
32
|
-
fileConfig = parsed?.notifications || {}
|
|
33
|
-
} catch (err) {
|
|
34
|
-
// Silently ignore parse errors
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Helper to get value with default
|
|
39
|
-
const get = (key, defaultValue) => {
|
|
40
|
-
if (fileConfig[key] !== undefined && fileConfig[key] !== '') {
|
|
41
|
-
return fileConfig[key]
|
|
42
|
-
}
|
|
43
|
-
return defaultValue
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Helper to parse boolean
|
|
47
|
-
const getBool = (key, defaultValue) => {
|
|
48
|
-
const value = get(key, undefined)
|
|
49
|
-
if (value === undefined) return defaultValue
|
|
50
|
-
if (typeof value === 'boolean') return value
|
|
51
|
-
return String(value).toLowerCase() !== 'false' && String(value) !== '0'
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Helper to parse int
|
|
55
|
-
const getInt = (key, defaultValue) => {
|
|
56
|
-
const value = get(key, undefined)
|
|
57
|
-
if (value === undefined) return defaultValue
|
|
58
|
-
if (typeof value === 'number') return value
|
|
59
|
-
const parsed = parseInt(String(value), 10)
|
|
60
|
-
return isNaN(parsed) ? defaultValue : parsed
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
topic: get('topic', null),
|
|
65
|
-
server: get('server', 'https://ntfy.sh'),
|
|
66
|
-
authToken: get('token', null),
|
|
67
|
-
idleDelayMs: getInt('idle_delay_ms', 300000),
|
|
68
|
-
errorNotify: getBool('error_notify', true),
|
|
69
|
-
errorDebounceMs: getInt('error_debounce_ms', 60000),
|
|
70
|
-
retryNotifyFirst: getBool('retry_notify_first', true),
|
|
71
|
-
retryNotifyAfter: getInt('retry_notify_after', 3),
|
|
72
|
-
idleNotify: getBool('idle_notify', true),
|
|
73
|
-
debug: getBool('debug', false),
|
|
74
|
-
debugPath: get('debug_path', null),
|
|
75
|
-
}
|
|
76
|
-
}
|
package/plugin/logger.js
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
// Debug logging module for opencode-pilot plugin
|
|
2
|
-
// Writes to ~/.config/opencode-pilot/debug.log when enabled via NTFY_DEBUG=true or config.debug
|
|
3
|
-
//
|
|
4
|
-
// Usage:
|
|
5
|
-
// import { initLogger, debug } from './logger.js'
|
|
6
|
-
// initLogger({ debug: true, debugPath: '/custom/path.log' })
|
|
7
|
-
// debug('Event received', { type: 'session.status', status: 'idle' })
|
|
8
|
-
|
|
9
|
-
import { appendFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs'
|
|
10
|
-
import { join, dirname } from 'path'
|
|
11
|
-
import { homedir } from 'os'
|
|
12
|
-
|
|
13
|
-
// Maximum log file size before rotation (1MB)
|
|
14
|
-
export const MAX_LOG_SIZE = 1024 * 1024
|
|
15
|
-
|
|
16
|
-
// Default log path
|
|
17
|
-
const DEFAULT_LOG_PATH = join(homedir(), '.config', 'opencode-pilot', 'debug.log')
|
|
18
|
-
|
|
19
|
-
// Module state
|
|
20
|
-
let enabled = false
|
|
21
|
-
let logPath = DEFAULT_LOG_PATH
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Initialize the logger with configuration
|
|
25
|
-
* @param {Object} options
|
|
26
|
-
* @param {boolean} [options.debug] - Enable debug logging
|
|
27
|
-
* @param {string} [options.debugPath] - Custom log file path
|
|
28
|
-
*/
|
|
29
|
-
export function initLogger(options = {}) {
|
|
30
|
-
// Check environment variables first, then options
|
|
31
|
-
const envDebug = process.env.NTFY_DEBUG
|
|
32
|
-
const envDebugPath = process.env.NTFY_DEBUG_PATH
|
|
33
|
-
|
|
34
|
-
// Enable if NTFY_DEBUG is set to any truthy value (not 'false' or '0')
|
|
35
|
-
if (envDebug !== undefined && envDebug !== '' && envDebug !== 'false' && envDebug !== '0') {
|
|
36
|
-
enabled = true
|
|
37
|
-
} else if (options.debug !== undefined) {
|
|
38
|
-
enabled = Boolean(options.debug)
|
|
39
|
-
} else {
|
|
40
|
-
enabled = false
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Set log path (env var takes precedence)
|
|
44
|
-
if (envDebugPath) {
|
|
45
|
-
logPath = envDebugPath
|
|
46
|
-
} else if (options.debugPath) {
|
|
47
|
-
logPath = options.debugPath
|
|
48
|
-
} else {
|
|
49
|
-
logPath = DEFAULT_LOG_PATH
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Create directory if it doesn't exist
|
|
53
|
-
if (enabled) {
|
|
54
|
-
try {
|
|
55
|
-
const dir = dirname(logPath)
|
|
56
|
-
if (!existsSync(dir)) {
|
|
57
|
-
mkdirSync(dir, { recursive: true })
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
// Silently ignore directory creation errors
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Write a debug log entry
|
|
67
|
-
* @param {string} message - Log message
|
|
68
|
-
* @param {Object} [data] - Optional data to include
|
|
69
|
-
*/
|
|
70
|
-
export function debug(message, data) {
|
|
71
|
-
if (!enabled) {
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
// Check file size and rotate if needed
|
|
77
|
-
rotateIfNeeded()
|
|
78
|
-
|
|
79
|
-
// Format log entry with ISO 8601 timestamp
|
|
80
|
-
const timestamp = new Date().toISOString()
|
|
81
|
-
let entry = `[${timestamp}] ${message}`
|
|
82
|
-
|
|
83
|
-
// Append data if provided
|
|
84
|
-
if (data !== undefined) {
|
|
85
|
-
if (typeof data === 'object') {
|
|
86
|
-
entry += ' ' + JSON.stringify(data)
|
|
87
|
-
} else {
|
|
88
|
-
entry += ' ' + String(data)
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
entry += '\n'
|
|
93
|
-
|
|
94
|
-
// Ensure directory exists
|
|
95
|
-
const dir = dirname(logPath)
|
|
96
|
-
if (!existsSync(dir)) {
|
|
97
|
-
mkdirSync(dir, { recursive: true })
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Append to log file
|
|
101
|
-
appendFileSync(logPath, entry)
|
|
102
|
-
} catch {
|
|
103
|
-
// Silently ignore write errors to avoid affecting the plugin
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Rotate log file if it exceeds MAX_LOG_SIZE
|
|
109
|
-
*/
|
|
110
|
-
function rotateIfNeeded() {
|
|
111
|
-
try {
|
|
112
|
-
if (!existsSync(logPath)) {
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const stats = statSync(logPath)
|
|
117
|
-
if (stats.size > MAX_LOG_SIZE) {
|
|
118
|
-
// Simple rotation: just truncate the file
|
|
119
|
-
// For more sophisticated rotation, could rename to .old first
|
|
120
|
-
unlinkSync(logPath)
|
|
121
|
-
}
|
|
122
|
-
} catch {
|
|
123
|
-
// Silently ignore rotation errors
|
|
124
|
-
}
|
|
125
|
-
}
|
package/plugin/notifier.js
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
// ntfy HTTP client for sending notifications
|
|
2
|
-
|
|
3
|
-
import { debug } from './logger.js'
|
|
4
|
-
|
|
5
|
-
// Deduplication cache: track recently sent notifications to prevent duplicates
|
|
6
|
-
// Key: hash of notification content, Value: timestamp
|
|
7
|
-
const recentNotifications = new Map()
|
|
8
|
-
const DEDUPE_WINDOW_MS = 5000 // 5 seconds
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Generate a simple hash for deduplication
|
|
12
|
-
* @param {string} str - String to hash
|
|
13
|
-
* @returns {string} Simple hash
|
|
14
|
-
*/
|
|
15
|
-
function simpleHash(str) {
|
|
16
|
-
let hash = 0
|
|
17
|
-
for (let i = 0; i < str.length; i++) {
|
|
18
|
-
const char = str.charCodeAt(i)
|
|
19
|
-
hash = ((hash << 5) - hash) + char
|
|
20
|
-
hash = hash & hash // Convert to 32bit integer
|
|
21
|
-
}
|
|
22
|
-
return hash.toString(36)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Check if notification was recently sent (for deduplication)
|
|
27
|
-
* @param {string} key - Deduplication key
|
|
28
|
-
* @returns {boolean} True if duplicate
|
|
29
|
-
*/
|
|
30
|
-
function isDuplicate(key) {
|
|
31
|
-
const now = Date.now()
|
|
32
|
-
|
|
33
|
-
// Clean up old entries
|
|
34
|
-
for (const [k, timestamp] of recentNotifications) {
|
|
35
|
-
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
|
36
|
-
recentNotifications.delete(k)
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (recentNotifications.has(key)) {
|
|
41
|
-
return true
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
recentNotifications.set(key, now)
|
|
45
|
-
return false
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Build headers for ntfy requests
|
|
50
|
-
* @param {string} [authToken] - Optional ntfy access token for Bearer auth
|
|
51
|
-
* @returns {Object} Headers object
|
|
52
|
-
*/
|
|
53
|
-
function buildHeaders(authToken) {
|
|
54
|
-
const headers = {
|
|
55
|
-
'Content-Type': 'application/json',
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (authToken) {
|
|
59
|
-
headers['Authorization'] = `Bearer ${authToken}`
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return headers
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Send a basic notification to ntfy
|
|
67
|
-
* @param {Object} options
|
|
68
|
-
* @param {string} options.server - ntfy server URL
|
|
69
|
-
* @param {string} options.topic - ntfy topic name
|
|
70
|
-
* @param {string} options.title - Notification title
|
|
71
|
-
* @param {string} options.message - Notification message
|
|
72
|
-
* @param {number} [options.priority] - Priority (1-5, default 3)
|
|
73
|
-
* @param {string[]} [options.tags] - Emoji tags
|
|
74
|
-
* @param {string} [options.authToken] - Optional ntfy access token for protected topics
|
|
75
|
-
*/
|
|
76
|
-
export async function sendNotification({ server, topic, title, message, priority, tags, authToken }) {
|
|
77
|
-
// Deduplicate: skip if same notification sent recently
|
|
78
|
-
const dedupeKey = simpleHash(`${topic}:${title}:${message}`)
|
|
79
|
-
if (isDuplicate(dedupeKey)) {
|
|
80
|
-
debug(`Notification skipped (duplicate): ${title}`)
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const body = {
|
|
85
|
-
topic,
|
|
86
|
-
title,
|
|
87
|
-
message,
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Add optional fields only if provided
|
|
91
|
-
if (priority !== undefined) {
|
|
92
|
-
body.priority = priority
|
|
93
|
-
}
|
|
94
|
-
if (tags && tags.length > 0) {
|
|
95
|
-
body.tags = tags
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
debug(`Notification sending: ${title}`)
|
|
100
|
-
const response = await fetch(server, {
|
|
101
|
-
method: 'POST',
|
|
102
|
-
headers: buildHeaders(authToken),
|
|
103
|
-
body: JSON.stringify(body),
|
|
104
|
-
})
|
|
105
|
-
debug(`Notification sent: ${title} (status=${response.status})`)
|
|
106
|
-
} catch (error) {
|
|
107
|
-
debug(`Notification failed: ${title} (error=${error.message})`)
|
|
108
|
-
// Silently ignore - errors here shouldn't affect the user
|
|
109
|
-
}
|
|
110
|
-
}
|