@dotdrelle/wiki-manager 0.6.47 → 0.8.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.
Files changed (48) hide show
  1. package/.env.example +20 -0
  2. package/README.md +35 -1
  3. package/bin/wiki-manager.js +2 -2
  4. package/docker-compose.yml +2 -0
  5. package/mcp.endpoints.example.json +13 -0
  6. package/package.json +2 -2
  7. package/src/agent/graph.js +101 -15
  8. package/src/agent/graph.test.js +145 -0
  9. package/src/cli/wiki-manager.js +459 -131
  10. package/src/commands/slash.js +1 -1
  11. package/src/core/activity.js +14 -0
  12. package/src/core/agentEvents.js +148 -6
  13. package/src/core/agentEvents.test.js +145 -4
  14. package/src/core/agentLoop.js +167 -0
  15. package/src/core/agentLoop.test.js +85 -0
  16. package/src/core/cacert.js +4 -3
  17. package/src/core/dockerCompose.test.js +14 -0
  18. package/src/core/documentIntake.test.js +7 -7
  19. package/src/core/env.js +6 -0
  20. package/src/core/jobQueue.js +47 -16
  21. package/src/core/mcp.js +120 -10
  22. package/src/core/mcp.test.js +121 -1
  23. package/src/core/plan.js +33 -0
  24. package/src/core/queueStore.js +27 -0
  25. package/src/core/queueStore.test.js +32 -0
  26. package/src/core/wikiWorkspace.test.js +24 -0
  27. package/src/core/workspaces.js +8 -1
  28. package/src/runtime/approvals.js +113 -0
  29. package/src/runtime/auth.js +35 -0
  30. package/src/runtime/auth.test.js +29 -0
  31. package/src/runtime/client.js +154 -0
  32. package/src/runtime/lifecycle.js +84 -0
  33. package/src/runtime/queueStore.js +6 -0
  34. package/src/runtime/runner.js +413 -0
  35. package/src/runtime/runner.test.js +270 -0
  36. package/src/runtime/server.js +216 -0
  37. package/src/runtime/server.test.js +308 -0
  38. package/src/runtime/store.js +431 -0
  39. package/src/runtime/store.test.js +437 -0
  40. package/src/runtime/supervisor.js +104 -0
  41. package/src/runtime/supervisor.test.js +240 -0
  42. package/src/shell/RightPane.tsx +1 -1
  43. package/src/shell/repl.js +148 -18
  44. package/src/shell/repl.test.js +38 -0
  45. package/src/shell/tui.tsx +4 -1
  46. package/src/shell/useAgent.ts +20 -2
  47. package/src/shell/useSession.ts +153 -13
  48. package/wiki-workspace +221 -22
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { enqueueProductionJob, ensureJobQueue } from './jobQueue.js';
4
+
5
+ test('job queue uses an injected queue store', () => {
6
+ const queue = [];
7
+ let changed = 0;
8
+ const session = {
9
+ workspace: 'docs',
10
+ queueStore: {
11
+ list() {
12
+ return queue;
13
+ },
14
+ replace(next) {
15
+ queue.splice(0, queue.length, ...next);
16
+ changed += 1;
17
+ return queue;
18
+ },
19
+ changed() {
20
+ changed += 1;
21
+ },
22
+ },
23
+ };
24
+
25
+ const item = enqueueProductionJob(session, { type: 'build' }, 'workspace_busy');
26
+
27
+ assert.equal(item.workspace, 'docs');
28
+ assert.match(item.id, /^q-[0-9a-f-]{36}$/);
29
+ assert.equal(queue.length, 1);
30
+ assert.equal(ensureJobQueue(session), queue);
31
+ assert.equal(changed, 1);
32
+ });
@@ -0,0 +1,24 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+
5
+ test('wiki-workspace autostarts host runtime before workspace services', async () => {
6
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
7
+
8
+ assert.match(script, /ensure_runtime_up\(\) \{/);
9
+ assert.match(script, /WIKI_MANAGER_RUNTIME_AUTOSTART:-1/);
10
+ assert.match(script, /Starting host agent-runtime/);
11
+ assert.match(script, /start_workspace_services\(\) \{\n ensure_runtime_up\n compose_for_workspace "\$1" up -d serve mcp-http production-mcp/);
12
+ assert.match(script, /start_workspace_services "\$workspace"\n\n local serve_port prod_port/);
13
+ assert.match(script, /start_workspace_services "\$workspace"\n local serve_port production_port/);
14
+ assert.match(script, /ensure_runtime_up\n printf 'Starting mcp-http/);
15
+ });
16
+
17
+ test('wiki-workspace checks runtime pid command before killing', async () => {
18
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
19
+
20
+ assert.match(script, /runtime_pid_command\(\) \{/);
21
+ assert.match(script, /runtime_pid_matches\(\) \{/);
22
+ assert.match(script, /if ! runtime_pid_matches; then\n printf 'refusing to stop pid/);
23
+ assert.match(script, /kill "\$\(cat "\$pid_file"\)"/);
24
+ });
@@ -33,12 +33,19 @@ export function listWorkspaces() {
33
33
  if (!existsSync(envFile)) return [];
34
34
  const env = readEnvFile(envFile);
35
35
  const workspacePath = env.WIKI_WORKSPACE_PATH || registryPath;
36
+ let resolvedWorkspacePath;
37
+ try {
38
+ resolvedWorkspacePath = realpathSync.native?.(workspacePath) ?? realpathSync(workspacePath);
39
+ } catch {
40
+ // Host-absolute WIKI_WORKSPACE_PATH not accessible here (e.g. inside a container); use registry dir.
41
+ resolvedWorkspacePath = registryPath;
42
+ }
36
43
  return [
37
44
  {
38
45
  name: env.WORKSPACE_NAME || entry.name,
39
46
  registryPath,
40
47
  envFile,
41
- workspacePath: realpathSync.native?.(workspacePath) ?? realpathSync(workspacePath),
48
+ workspacePath: resolvedWorkspacePath,
42
49
  env,
43
50
  },
44
51
  ];
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
+
4
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
5
+
6
+ export function createApprovalManager(session, {
7
+ defaultTimeoutMs = DEFAULT_APPROVAL_TIMEOUT_MS,
8
+ } = {}) {
9
+ const pending = new Map();
10
+
11
+ async function requestApproval(request = {}) {
12
+ const scope = request.scope === 'tool' ? 'tool' : 'run';
13
+ const approvalId = request.approvalId ?? randomUUID();
14
+ const runId = request.runId ?? session._currentRunIdentity?.runId ?? null;
15
+ const timeoutMs = Number.isFinite(Number(request.timeoutMs))
16
+ ? Math.max(1, Number(request.timeoutMs))
17
+ : defaultTimeoutMs;
18
+ const eventType = scope === 'tool' ? 'tool_pending_approval' : 'run_pending_approval';
19
+ const approvedType = scope === 'tool' ? 'tool_approved' : 'run_approved';
20
+
21
+ dispatchAgentEvent(session, createAgentEvent(eventType, {
22
+ origin: 'runtime',
23
+ runId,
24
+ payload: {
25
+ approvalId,
26
+ runId,
27
+ itemId: request.itemId ?? null,
28
+ reason: request.reason ?? null,
29
+ tool: request.tool ?? null,
30
+ plan: request.plan ?? null,
31
+ timeoutMs,
32
+ },
33
+ }));
34
+
35
+ await new Promise((resolve, reject) => {
36
+ const timer = setTimeout(() => {
37
+ pending.delete(approvalId);
38
+ const err = new Error(`Approval timed out: ${approvalId}`);
39
+ err.name = 'ApprovalError';
40
+ reject(err);
41
+ }, timeoutMs);
42
+ const cleanup = () => {
43
+ clearTimeout(timer);
44
+ request.signal?.removeEventListener('abort', onAbort);
45
+ };
46
+ const onAbort = () => {
47
+ pending.delete(approvalId);
48
+ cleanup();
49
+ const err = new Error(`Approval cancelled: ${approvalId}`);
50
+ err.name = 'AbortError';
51
+ reject(err);
52
+ };
53
+ pending.set(approvalId, {
54
+ approvalId,
55
+ scope,
56
+ runId,
57
+ itemId: request.itemId ?? null,
58
+ resolve: () => {
59
+ pending.delete(approvalId);
60
+ cleanup();
61
+ resolve();
62
+ },
63
+ });
64
+ if (request.signal?.aborted) {
65
+ onAbort();
66
+ return;
67
+ }
68
+ request.signal?.addEventListener('abort', onAbort, { once: true });
69
+ });
70
+
71
+ dispatchAgentEvent(session, createAgentEvent(approvedType, {
72
+ origin: 'runtime',
73
+ runId,
74
+ payload: {
75
+ approvalId,
76
+ runId,
77
+ itemId: request.itemId ?? null,
78
+ },
79
+ }));
80
+ return { approved: true, approvalId, runId, itemId: request.itemId ?? null };
81
+ }
82
+
83
+ function approve({ approvalId = null, runId = null, itemId = null } = {}) {
84
+ const entry = approvalId
85
+ ? pending.get(approvalId)
86
+ : [...pending.values()].find((item) =>
87
+ (runId && item.runId === runId) || (itemId && item.itemId === itemId),
88
+ );
89
+ if (!entry) return { approved: false, reason: 'approval not found' };
90
+ entry.resolve();
91
+ return {
92
+ approved: true,
93
+ approvalId: entry.approvalId,
94
+ runId: entry.runId,
95
+ itemId: entry.itemId,
96
+ };
97
+ }
98
+
99
+ function list() {
100
+ return [...pending.entries()].map(([approvalId, item]) => ({
101
+ approvalId,
102
+ scope: item.scope,
103
+ runId: item.runId,
104
+ itemId: item.itemId,
105
+ }));
106
+ }
107
+
108
+ return {
109
+ requestApproval,
110
+ approve,
111
+ list,
112
+ };
113
+ }
@@ -0,0 +1,35 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { defaultRuntimeStateDir } from '../core/env.js';
5
+
6
+ export function runtimeTokenPath(stateDir = defaultRuntimeStateDir()) {
7
+ return join(resolve(stateDir), 'runtime.token');
8
+ }
9
+
10
+ export function runtimeTokenFromEnv() {
11
+ return process.env.WIKI_MANAGER_RUNTIME_TOKEN ?? process.env.RUNTIME_AUTH_TOKEN ?? null;
12
+ }
13
+
14
+ export function resolveRuntimeAuthToken({
15
+ host = '127.0.0.1',
16
+ stateDir = defaultRuntimeStateDir(),
17
+ explicitToken = runtimeTokenFromEnv(),
18
+ } = {}) {
19
+ if (explicitToken) return { token: explicitToken, source: 'env', tokenPath: null };
20
+ if (!isExposedHost(host)) return { token: null, source: 'none', tokenPath: null };
21
+
22
+ const tokenPath = runtimeTokenPath(stateDir);
23
+ if (existsSync(tokenPath)) {
24
+ const token = readFileSync(tokenPath, 'utf8').trim();
25
+ if (token) return { token, source: 'file', tokenPath };
26
+ }
27
+ const token = randomBytes(32).toString('hex');
28
+ mkdirSync(resolve(stateDir), { recursive: true });
29
+ writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
30
+ return { token, source: 'generated', tokenPath };
31
+ }
32
+
33
+ export function isExposedHost(host) {
34
+ return ['0.0.0.0', '::', '[::]'].includes(String(host ?? '').trim());
35
+ }
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import { resolveRuntimeAuthToken } from './auth.js';
7
+ import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
8
+
9
+ test('resolveRuntimeAuthToken: loopback host does not require token', () => {
10
+ const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
11
+ assert.equal(result.token, null);
12
+ assert.equal(result.source, 'none');
13
+ });
14
+
15
+ test('resolveRuntimeAuthToken: exposed host generates and reuses token', () => {
16
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-auth-'));
17
+ const first = resolveRuntimeAuthToken({ host: '0.0.0.0', stateDir, explicitToken: null });
18
+ const second = resolveRuntimeAuthToken({ host: '0.0.0.0', stateDir, explicitToken: null });
19
+ assert.equal(first.source, 'generated');
20
+ assert.equal(second.source, 'file');
21
+ assert.equal(second.token, first.token);
22
+ });
23
+
24
+ test('runtime lifecycle uses a Node executable with node:sqlite support', async () => {
25
+ const runtimeNode = await assertRuntimeNode(runtimeNodeExecutable());
26
+
27
+ assert.ok(runtimeNode.executable);
28
+ assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
29
+ });
@@ -0,0 +1,154 @@
1
+ import { runtimeTokenFromEnv as runtimeToken } from './auth.js';
2
+
3
+ function base(url) {
4
+ return url.replace(/\/$/, '');
5
+ }
6
+
7
+ function runtimeEndpoint(url, path, workspace = null) {
8
+ const endpoint = new URL(`${base(url)}${path}`);
9
+ if (workspace) endpoint.searchParams.set('workspace', workspace);
10
+ return endpoint.toString();
11
+ }
12
+
13
+ export function runtimeUrlFromEnv() {
14
+ return process.env.WIKI_MANAGER_RUNTIME_URL ?? 'http://127.0.0.1:7788';
15
+ }
16
+
17
+ export async function fetchRuntimeState({
18
+ url = runtimeUrlFromEnv(),
19
+ token = runtimeToken(),
20
+ workspace = null,
21
+ } = {}) {
22
+ const response = await fetch(runtimeEndpoint(url, '/state', workspace), {
23
+ headers: runtimeHeaders(token),
24
+ });
25
+ if (!response.ok) throw new Error(`Runtime state failed: HTTP ${response.status}`);
26
+ return response.json();
27
+ }
28
+
29
+ export async function checkRuntimeHealth({
30
+ url = runtimeUrlFromEnv(),
31
+ token = runtimeToken(),
32
+ workspace = null,
33
+ } = {}) {
34
+ const response = await fetch(runtimeEndpoint(url, '/health', workspace), {
35
+ headers: runtimeHeaders(token),
36
+ });
37
+ if (!response.ok) return null;
38
+ return response.json();
39
+ }
40
+
41
+ export async function postRuntimeRun(input, {
42
+ url = runtimeUrlFromEnv(),
43
+ token = runtimeToken(),
44
+ workspace = null,
45
+ evaluate = undefined,
46
+ replans = undefined,
47
+ } = {}) {
48
+ const response = await fetch(runtimeEndpoint(url, '/run', workspace), {
49
+ method: 'POST',
50
+ headers: {
51
+ ...runtimeHeaders(token),
52
+ 'Content-Type': 'application/json',
53
+ },
54
+ body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans })),
55
+ });
56
+ if (!response.ok) throw new Error(`Runtime run failed: HTTP ${response.status}`);
57
+ return response.json();
58
+ }
59
+
60
+ export async function postRuntimeCancel({
61
+ url = runtimeUrlFromEnv(),
62
+ token = runtimeToken(),
63
+ workspace = null,
64
+ } = {}) {
65
+ const response = await fetch(runtimeEndpoint(url, '/cancel', workspace), {
66
+ method: 'POST',
67
+ headers: runtimeHeaders(token),
68
+ });
69
+ if (!response.ok && response.status !== 501) throw new Error(`Runtime cancel failed: HTTP ${response.status}`);
70
+ return response.json();
71
+ }
72
+
73
+ export async function postRuntimeResume({
74
+ url = runtimeUrlFromEnv(),
75
+ token = runtimeToken(),
76
+ workspace = null,
77
+ } = {}) {
78
+ const response = await fetch(runtimeEndpoint(url, '/resume', workspace), {
79
+ method: 'POST',
80
+ headers: runtimeHeaders(token),
81
+ });
82
+ if (!response.ok) throw new Error(`Runtime resume failed: HTTP ${response.status}`);
83
+ return response.json();
84
+ }
85
+
86
+ export async function postRuntimeApprove({
87
+ url = runtimeUrlFromEnv(),
88
+ token = runtimeToken(),
89
+ workspace = null,
90
+ runId = null,
91
+ itemId = null,
92
+ approvalId = null,
93
+ } = {}) {
94
+ const endpoint = runtimeEndpoint(url, '/approve', workspace);
95
+ const parsed = new URL(endpoint);
96
+ if (runId) parsed.searchParams.set('runId', runId);
97
+ if (itemId) parsed.searchParams.set('itemId', itemId);
98
+ if (approvalId) parsed.searchParams.set('approvalId', approvalId);
99
+ const response = await fetch(parsed.toString(), {
100
+ method: 'POST',
101
+ headers: runtimeHeaders(token),
102
+ });
103
+ if (!response.ok) throw new Error(`Runtime approve failed: HTTP ${response.status}`);
104
+ return response.json();
105
+ }
106
+
107
+ function runtimeHeaders(token) {
108
+ return token ? { Authorization: `Bearer ${token}` } : {};
109
+ }
110
+
111
+ export async function* streamRuntimeEvents({
112
+ url = runtimeUrlFromEnv(),
113
+ token = runtimeToken(),
114
+ signal = null,
115
+ workspace = null,
116
+ } = {}) {
117
+ const response = await fetch(runtimeEndpoint(url, '/events/stream', workspace), {
118
+ headers: { ...runtimeHeaders(token), Accept: 'text/event-stream' },
119
+ signal,
120
+ });
121
+ if (!response.ok) throw new Error(`Runtime SSE connect failed: HTTP ${response.status}`);
122
+ const reader = response.body.getReader();
123
+ const decoder = new TextDecoder();
124
+ let buffer = '';
125
+ try {
126
+ while (true) {
127
+ const { done, value } = await reader.read();
128
+ if (done) break;
129
+ buffer += decoder.decode(value, { stream: true });
130
+ const blocks = buffer.split('\n\n');
131
+ buffer = blocks.pop() ?? '';
132
+ for (const block of blocks) {
133
+ let type = 'message';
134
+ let data = '';
135
+ for (const line of block.split('\n')) {
136
+ if (line.startsWith('event: ')) type = line.slice(7).trim();
137
+ else if (line.startsWith('data: ')) data = line.slice(6);
138
+ }
139
+ if (!data) continue;
140
+ try {
141
+ yield { type, data: JSON.parse(data) };
142
+ } catch {
143
+ // malformed frame — skip
144
+ }
145
+ }
146
+ }
147
+ } finally {
148
+ reader.releaseLock();
149
+ }
150
+ }
151
+
152
+ export function runtimeFetchOptions(token = runtimeToken()) {
153
+ return { headers: runtimeHeaders(token) };
154
+ }
@@ -0,0 +1,84 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { checkRuntimeHealth, runtimeUrlFromEnv } from './client.js';
5
+ import { defaultRuntimeStateDir } from '../core/env.js';
6
+ import { resolveRuntimeAuthToken, runtimeTokenFromEnv } from './auth.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const managerRoot = resolve(__dirname, '../..');
10
+ const binPath = resolve(managerRoot, 'bin/wiki-manager.js');
11
+
12
+ export function runtimeNodeExecutable() {
13
+ return process.versions.bun
14
+ ? (process.env.WIKI_MANAGER_NODE_BIN ?? 'node')
15
+ : process.execPath;
16
+ }
17
+
18
+ export async function assertRuntimeNode(executable = runtimeNodeExecutable()) {
19
+ const version = await new Promise((resolveVersion, reject) => {
20
+ execFile(executable, ['-p', 'process.versions.node'], (err, stdout) => {
21
+ if (err) {
22
+ reject(new Error(`Runtime requires Node.js 22+; could not execute ${executable}. Set WIKI_MANAGER_NODE_BIN to a Node.js 22 binary.`));
23
+ return;
24
+ }
25
+ resolveVersion(String(stdout).trim());
26
+ });
27
+ });
28
+ const major = Number(String(version).split('.')[0]);
29
+ if (!Number.isInteger(major) || major < 22) {
30
+ throw new Error(`Runtime requires Node.js 22+ for node:sqlite; ${executable} is Node ${version}. Set WIKI_MANAGER_NODE_BIN to a Node.js 22 binary.`);
31
+ }
32
+ return { executable, version };
33
+ }
34
+
35
+ export async function ensureRuntime({
36
+ host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0',
37
+ port = Number(process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788),
38
+ stateDir = process.env.WIKI_MANAGER_STATE_DIR ?? defaultRuntimeStateDir(),
39
+ url = process.env.WIKI_MANAGER_RUNTIME_URL ?? `http://127.0.0.1:${port}`,
40
+ timeoutMs = 5000,
41
+ } = {}) {
42
+ const auth = resolveRuntimeAuthToken({ host, stateDir });
43
+ if (auth.token) process.env.WIKI_MANAGER_RUNTIME_TOKEN = auth.token;
44
+ const existing = await runtimeHealthOrNull(url, auth.token);
45
+ if (existing) return { url, started: false, health: existing, token: auth.token, tokenPath: auth.tokenPath };
46
+
47
+ const runtimeNode = await assertRuntimeNode();
48
+ const child = spawn(runtimeNode.executable, [
49
+ binPath,
50
+ 'runtime',
51
+ '--host',
52
+ host,
53
+ '--port',
54
+ String(port),
55
+ '--state-dir',
56
+ stateDir,
57
+ ], {
58
+ detached: true,
59
+ stdio: 'ignore',
60
+ env: {
61
+ ...process.env,
62
+ ...(auth.token ? { WIKI_MANAGER_RUNTIME_TOKEN: auth.token } : {}),
63
+ WIKI_MANAGER_RUNTIME_CHILD: '1',
64
+ },
65
+ });
66
+ child.unref();
67
+
68
+ const deadline = Date.now() + timeoutMs;
69
+ let health = null;
70
+ while (Date.now() < deadline) {
71
+ health = await runtimeHealthOrNull(url, auth.token);
72
+ if (health) return { url, started: true, health, pid: child.pid, token: auth.token, tokenPath: auth.tokenPath, node: runtimeNode };
73
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
74
+ }
75
+ throw new Error(`Runtime did not become healthy at ${url}`);
76
+ }
77
+
78
+ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = runtimeTokenFromEnv()) {
79
+ try {
80
+ return await checkRuntimeHealth({ url, token });
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
@@ -0,0 +1,6 @@
1
+ import { createQueueStore } from '../core/queueStore.js';
2
+
3
+ export function createSqliteQueueStore(store, session, { workspace = session.workspace ?? null } = {}) {
4
+ session.jobQueue = store.listQueue({ workspace });
5
+ return createQueueStore(session, { persist: () => store.saveQueue(session.jobQueue, { workspace }) });
6
+ }