@dotdrelle/wiki-manager 0.15.29 → 0.15.32
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/.env.example +5 -2
- package/agents.docker-compose.yml +10 -0
- package/bin/wiki-manager.js +1 -1
- package/docker-compose.override.example.yml +1 -0
- package/package.json +1 -1
- package/src/agent/graph.test.js +17 -4
- package/src/cli/runtimeStartup.test.js +14 -0
- package/src/cli/wiki-manager.js +79 -13
- package/src/cli/wiki-manager.test.js +157 -0
- package/src/commands/slash.js +9 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +113 -38
- package/src/core/mcp.test.js +181 -20
- package/src/core/wikiWorkspace.test.js +16 -0
- package/src/orchestrator/agentRegistry.js +35 -7
- package/src/orchestrator/agentRegistry.test.js +73 -0
- package/src/orchestrator/dispatcher.js +6 -1
- package/src/orchestrator/dispatcher.test.js +24 -1
- package/src/runtime/auth.test.js +1 -65
- package/src/runtime/donna-contract.test.js +2 -1
- package/src/runtime/lifecycle.js +0 -33
- package/src/runtime/runner.test.js +10 -1
- package/src/runtime/supervisor.test.js +11 -1
- package/src/shell/tui.tsx +10 -18
- package/wiki-workspace +69 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { createDispatcher } from './dispatcher.js';
|
|
3
|
+
import { createDispatcher, normalizeTaskError } from './dispatcher.js';
|
|
4
4
|
|
|
5
5
|
test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
|
|
6
6
|
const session = {
|
|
@@ -159,3 +159,26 @@ test('dispatcher keeps a null error when a terminal status reports none', async
|
|
|
159
159
|
assert.equal(result.ok, true);
|
|
160
160
|
assert.equal(result.error, null);
|
|
161
161
|
});
|
|
162
|
+
|
|
163
|
+
test('normalizeTaskError keeps the agent reason as the message, never the fallback', () => {
|
|
164
|
+
// Regression: the fallback describes only WHERE the failure was seen
|
|
165
|
+
// ("agent_execute rejected task"). Letting it win discarded the one
|
|
166
|
+
// actionable sentence — and left Donna to invent a cause.
|
|
167
|
+
const error = normalizeTaskError("Error: source 'acpi' not found", {
|
|
168
|
+
fallbackCode: 'execution_rejected',
|
|
169
|
+
fallbackMessage: 'agent_execute rejected task',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
assert.equal(error.code, "Error: source 'acpi' not found");
|
|
173
|
+
assert.equal(error.message, "Error: source 'acpi' not found");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('normalizeTaskError falls back only when the agent reports no reason at all', () => {
|
|
177
|
+
const error = normalizeTaskError('', {
|
|
178
|
+
fallbackCode: 'execution_rejected',
|
|
179
|
+
fallbackMessage: 'agent_execute rejected task',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
assert.equal(error.code, 'execution_rejected');
|
|
183
|
+
assert.equal(error.message, 'agent_execute rejected task');
|
|
184
|
+
});
|
package/src/runtime/auth.test.js
CHANGED
|
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import { resolveRuntimeAuthToken } from './auth.js';
|
|
7
|
-
import { assertRuntimeNode, runtimeNodeExecutable
|
|
7
|
+
import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
|
|
8
8
|
|
|
9
9
|
test('resolveRuntimeAuthToken: loopback host does not require token', () => {
|
|
10
10
|
const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
|
|
@@ -27,67 +27,3 @@ test('runtime lifecycle uses a Node executable with node:sqlite support', async
|
|
|
27
27
|
assert.ok(runtimeNode.executable);
|
|
28
28
|
assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
|
|
29
29
|
});
|
|
30
|
-
|
|
31
|
-
test('shutdownOwnedRuntime reports progress and stops an idle owned runtime', async () => {
|
|
32
|
-
const originalFetch = globalThis.fetch;
|
|
33
|
-
const calls = [];
|
|
34
|
-
const logs = [];
|
|
35
|
-
globalThis.fetch = async (url, options = {}) => {
|
|
36
|
-
calls.push({ url: String(url), method: options.method ?? 'GET' });
|
|
37
|
-
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
38
|
-
status: calls.length === 1 ? 200 : 202,
|
|
39
|
-
headers: { 'content-type': 'application/json' },
|
|
40
|
-
});
|
|
41
|
-
};
|
|
42
|
-
try {
|
|
43
|
-
const result = await shutdownOwnedRuntime(
|
|
44
|
-
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
45
|
-
{ log: (message) => logs.push(message), timeoutMs: 100 },
|
|
46
|
-
);
|
|
47
|
-
assert.equal(result.action, 'shutdown');
|
|
48
|
-
assert.deepEqual(calls.map((call) => call.method), ['GET', 'POST']);
|
|
49
|
-
assert.match(logs.join('\n'), /runtime arrêté/);
|
|
50
|
-
} finally {
|
|
51
|
-
globalThis.fetch = originalFetch;
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('shutdownOwnedRuntime also stops an idle reused runtime', async () => {
|
|
56
|
-
const originalFetch = globalThis.fetch;
|
|
57
|
-
const calls = [];
|
|
58
|
-
globalThis.fetch = async (_url, options = {}) => {
|
|
59
|
-
calls.push(options.method ?? 'GET');
|
|
60
|
-
return new Response(JSON.stringify(calls.length === 1 ? { activeRuns: [] } : { shutdown: true }), {
|
|
61
|
-
status: calls.length === 1 ? 200 : 202,
|
|
62
|
-
headers: { 'content-type': 'application/json' },
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
try {
|
|
66
|
-
const result = await shutdownOwnedRuntime(
|
|
67
|
-
{ url: 'http://127.0.0.1:7788', started: false, token: null },
|
|
68
|
-
{ timeoutMs: 100 },
|
|
69
|
-
);
|
|
70
|
-
assert.equal(result.action, 'shutdown');
|
|
71
|
-
assert.deepEqual(calls, ['GET', 'POST']);
|
|
72
|
-
} finally {
|
|
73
|
-
globalThis.fetch = originalFetch;
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('shutdownOwnedRuntime bounds an unresponsive shutdown', async () => {
|
|
78
|
-
const originalFetch = globalThis.fetch;
|
|
79
|
-
const logs = [];
|
|
80
|
-
globalThis.fetch = async (_url, { signal } = {}) => new Promise((_resolve, reject) => {
|
|
81
|
-
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
|
|
82
|
-
});
|
|
83
|
-
try {
|
|
84
|
-
const result = await shutdownOwnedRuntime(
|
|
85
|
-
{ url: 'http://127.0.0.1:7788', started: true, token: null },
|
|
86
|
-
{ log: (message) => logs.push(message), timeoutMs: 5 },
|
|
87
|
-
);
|
|
88
|
-
assert.equal(result.action, 'timeout');
|
|
89
|
-
assert.match(logs.join('\n'), /délai de fermeture/);
|
|
90
|
-
} finally {
|
|
91
|
-
globalThis.fetch = originalFetch;
|
|
92
|
-
}
|
|
93
|
-
});
|
|
@@ -146,7 +146,8 @@ test('CME setup and source configuration stay outside export orchestration', asy
|
|
|
146
146
|
const calledTools = [];
|
|
147
147
|
globalThis.fetch = async (_url, options = {}) => {
|
|
148
148
|
const body = JSON.parse(String(options.body ?? '{}'));
|
|
149
|
-
|
|
149
|
+
// Record tool calls only — the MCP session handshake is not a tool call.
|
|
150
|
+
if (body.method === 'tools/call') calledTools.push(body.params?.name);
|
|
150
151
|
return {
|
|
151
152
|
ok: true,
|
|
152
153
|
status: 200,
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -139,36 +139,3 @@ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = run
|
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
|
|
143
|
-
// Close the runtime attached to the shell when it is idle, including a process
|
|
144
|
-
// reused at startup. Restricting cleanup to `started: true` left ownerless
|
|
145
|
-
// runtimes occupying port 7788 with another manager directory/token.
|
|
146
|
-
// An active run still survives the shell.
|
|
147
|
-
export async function shutdownOwnedRuntime(runtime, { log = (_message) => {}, timeoutMs = 3000 } = {}) {
|
|
148
|
-
if (!runtime?.url) return { action: 'kept', reason: 'unavailable' };
|
|
149
|
-
const controller = new AbortController();
|
|
150
|
-
const timeout = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 3000));
|
|
151
|
-
try {
|
|
152
|
-
const health = await runtimeHealthOrNull(runtime.url, runtime.token, controller.signal);
|
|
153
|
-
if (!health) return { action: 'kept', reason: 'unreachable' };
|
|
154
|
-
const activeRuns = Array.isArray(health.activeRuns) ? health.activeRuns : [];
|
|
155
|
-
if (activeRuns.length > 0) {
|
|
156
|
-
const labels = activeRuns
|
|
157
|
-
.map((run) => [run.workspace, run.runId ? String(run.runId).slice(0, 8) : null].filter(Boolean).join('/'))
|
|
158
|
-
.join(', ');
|
|
159
|
-
log(`runtime laissé actif : run en cours (${labels}) — il survivra à ce shell ; relance wiki-manager pour le retrouver.`);
|
|
160
|
-
return { action: 'kept', reason: 'run_active', activeRuns };
|
|
161
|
-
}
|
|
162
|
-
await postRuntimeShutdown({ url: runtime.url, token: runtime.token, signal: controller.signal });
|
|
163
|
-
log('runtime arrêté (aucun run en cours).');
|
|
164
|
-
return { action: 'shutdown' };
|
|
165
|
-
} catch (err) {
|
|
166
|
-
if (controller.signal.aborted) {
|
|
167
|
-
log('délai de fermeture du runtime dépassé — le shell termine sans attendre davantage.');
|
|
168
|
-
return { action: 'timeout', reason: 'shutdown_timeout' };
|
|
169
|
-
}
|
|
170
|
-
return { action: 'error', reason: err instanceof Error ? err.message : String(err) };
|
|
171
|
-
} finally {
|
|
172
|
-
clearTimeout(timeout);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
@@ -401,7 +401,16 @@ test('replanRuntimeRun requires runtime approval for mutating replanned work', a
|
|
|
401
401
|
test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
|
|
402
402
|
const originalFetch = globalThis.fetch;
|
|
403
403
|
let pollAttempts = 0;
|
|
404
|
-
globalThis.fetch = async () => {
|
|
404
|
+
globalThis.fetch = async (_url, init) => {
|
|
405
|
+
// Count polls, not the one-off MCP session handshake.
|
|
406
|
+
if (JSON.parse(init.body).method === 'initialize') {
|
|
407
|
+
return {
|
|
408
|
+
ok: true,
|
|
409
|
+
status: 200,
|
|
410
|
+
headers: { get: () => null },
|
|
411
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
412
|
+
};
|
|
413
|
+
}
|
|
405
414
|
pollAttempts += 1;
|
|
406
415
|
return {
|
|
407
416
|
ok: true,
|
|
@@ -93,7 +93,17 @@ test('pollActivitiesOnce does not repeat an unchanged retry state when retryAt m
|
|
|
93
93
|
test('pollActivitiesOnce retries transient MCP poll failures', async () => {
|
|
94
94
|
const originalFetch = globalThis.fetch;
|
|
95
95
|
let attempts = 0;
|
|
96
|
-
globalThis.fetch = async () => {
|
|
96
|
+
globalThis.fetch = async (_url, init) => {
|
|
97
|
+
// The session handshake is not the thing under test: answer it, and make
|
|
98
|
+
// the first *poll* the transient failure.
|
|
99
|
+
if (JSON.parse(init.body).method === 'initialize') {
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
status: 200,
|
|
103
|
+
headers: { get: () => null },
|
|
104
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
97
107
|
attempts += 1;
|
|
98
108
|
if (attempts === 1) {
|
|
99
109
|
return {
|
package/src/shell/tui.tsx
CHANGED
|
@@ -150,30 +150,22 @@ function App(props: {
|
|
|
150
150
|
const [preflightBusy, setPreflightBusy] = createSignal(false);
|
|
151
151
|
let ctrlCTimer: ReturnType<typeof setTimeout> | null = null;
|
|
152
152
|
let exiting = false;
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// screen — 0.12.9 shipped that and killed the runtime mid-session.
|
|
153
|
+
// The runtime is shared infrastructure: `llm-wiki serve` can still be using
|
|
154
|
+
// it after this shell closes. Exiting the UI only destroys the renderer;
|
|
155
|
+
// explicit lifecycle commands own runtime shutdown.
|
|
157
156
|
const exitShell = () => {
|
|
158
157
|
if (exiting) return;
|
|
159
158
|
exiting = true;
|
|
160
159
|
setExitStatus('Fermeture enclenchée…');
|
|
161
|
-
// Register the complete shutdown before destroying the renderer: onDestroy
|
|
162
|
-
// resolves runOpenTuiShell, which must still await this cleanup task.
|
|
163
160
|
const task = Promise.resolve().then(async () => {
|
|
164
161
|
renderer.destroy();
|
|
165
|
-
console.log('[wiki-manager]
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
} catch {
|
|
173
|
-
// Best effort: never block the exit on runtime cleanup.
|
|
174
|
-
}
|
|
175
|
-
for (const message of messages) console.log(`[wiki-manager] ${message}`);
|
|
176
|
-
console.log('[wiki-manager] fermeture terminée — shell clos proprement.');
|
|
162
|
+
console.log('[wiki-manager] shell closed; shared runtime left running.');
|
|
163
|
+
// MCP clients, image-refresh probes, and reconnect timers may still own
|
|
164
|
+
// event-loop handles. They belong to this shell process, not to the
|
|
165
|
+
// detached shared runtime. Once the renderer is gone there is no useful
|
|
166
|
+
// foreground work left, so terminate explicitly instead of leaving the
|
|
167
|
+
// terminal pending indefinitely.
|
|
168
|
+
process.exit(0);
|
|
177
169
|
});
|
|
178
170
|
if (props.exitRef) props.exitRef.current = task;
|
|
179
171
|
};
|
package/wiki-workspace
CHANGED
|
@@ -84,6 +84,7 @@ Usage:
|
|
|
84
84
|
wiki-workspace [--cacert <path>] <command> ...
|
|
85
85
|
wiki-workspace config <workspace> [path]
|
|
86
86
|
wiki-workspace up <workspace>
|
|
87
|
+
wiki-workspace refresh
|
|
87
88
|
wiki-workspace wiki <workspace> <command> [args...]
|
|
88
89
|
wiki-workspace list
|
|
89
90
|
|
|
@@ -91,6 +92,7 @@ Commands:
|
|
|
91
92
|
config <workspace> [path] Create workspace folder, auto-select ports, run wiki init
|
|
92
93
|
up <workspace> [--open] Start workspace stack: wiki + production
|
|
93
94
|
list List configured workspaces
|
|
95
|
+
refresh Stop runtime/stacks and remove their Compose images
|
|
94
96
|
|
|
95
97
|
agents up [--pull] Start all external agents (cme, documents)
|
|
96
98
|
agents down Stop all external agents
|
|
@@ -260,6 +262,31 @@ agents_compose() {
|
|
|
260
262
|
local compose_env_args=()
|
|
261
263
|
[[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
|
|
262
264
|
|
|
265
|
+
# Local-iteration build only: the packaged connectors service carries a
|
|
266
|
+
# `build:` block pointing at the sibling agent-connectors repo, whose OAuth
|
|
267
|
+
# credentials live in a gitignored .env.build.local. Compose resolves the
|
|
268
|
+
# value-less build args from *this* process environment, not from
|
|
269
|
+
# --env-file, so the variables must be exported here or the image ships with
|
|
270
|
+
# empty WIKILLM_GOOGLE_OAUTH_* and every Google connector stays unusable.
|
|
271
|
+
local connectors_build_env="$ROOT_DIR/../agent-external/agent-connectors/.env.build.local"
|
|
272
|
+
if [[ -f "$connectors_build_env" ]]; then
|
|
273
|
+
local build_key build_value
|
|
274
|
+
while IFS='=' read -r build_key build_value; do
|
|
275
|
+
case "$build_key" in
|
|
276
|
+
WIKILLM_GOOGLE_OAUTH_CLIENT_ID|WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET) ;;
|
|
277
|
+
*) continue ;;
|
|
278
|
+
esac
|
|
279
|
+
[[ -n "${!build_key:-}" ]] || export "$build_key=$build_value"
|
|
280
|
+
done < <(grep -v '^[[:space:]]*#' "$connectors_build_env" || true)
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
require_connectors_build_credentials() {
|
|
284
|
+
local build_key
|
|
285
|
+
for build_key in WIKILLM_GOOGLE_OAUTH_CLIENT_ID WIKILLM_GOOGLE_OAUTH_CLIENT_SECRET; do
|
|
286
|
+
[[ -n "${!build_key:-}" ]] || die "cannot build connectors: $build_key is missing from $connectors_build_env"
|
|
287
|
+
done
|
|
288
|
+
}
|
|
289
|
+
|
|
263
290
|
_agents_dc() {
|
|
264
291
|
local cacert_args=()
|
|
265
292
|
local profile_args=()
|
|
@@ -288,6 +315,9 @@ agents_compose() {
|
|
|
288
315
|
[[ $# -eq 0 ]] || die "agents up accepts only --pull"
|
|
289
316
|
ensure_agent_tokens
|
|
290
317
|
ensure_endpoints_file
|
|
318
|
+
if connectors_enabled && [[ -f "$ROOT_DIR/../agent-external/agent-connectors/Dockerfile" ]]; then
|
|
319
|
+
require_connectors_build_credentials
|
|
320
|
+
fi
|
|
291
321
|
# The .env may have just been created above — recompute the env-file
|
|
292
322
|
# flag so freshly generated tokens reach the containers on first run.
|
|
293
323
|
compose_env_args=()
|
|
@@ -314,6 +344,10 @@ agents_compose() {
|
|
|
314
344
|
[[ $# -eq 0 ]] || die "agents down does not take arguments"
|
|
315
345
|
_agents_dc down
|
|
316
346
|
;;
|
|
347
|
+
purge)
|
|
348
|
+
[[ $# -eq 0 ]] || die "agents purge does not take arguments"
|
|
349
|
+
_agents_dc down --rmi all --remove-orphans
|
|
350
|
+
;;
|
|
317
351
|
logs)
|
|
318
352
|
local log_args=()
|
|
319
353
|
read_lines_into_array log_args logs_args "$@"
|
|
@@ -687,6 +721,19 @@ runtime_command() {
|
|
|
687
721
|
;;
|
|
688
722
|
down|stop)
|
|
689
723
|
[[ $# -eq 0 ]] || die "runtime down does not take arguments"
|
|
724
|
+
token="$(ensure_manager_runtime_token)"
|
|
725
|
+
if runtime_health_check; then
|
|
726
|
+
RUNTIME_URL="$(runtime_health_url)/shutdown" RUNTIME_TOKEN="$token" "$node_bin" --input-type=module -e \
|
|
727
|
+
"const h=process.env.RUNTIME_TOKEN?{Authorization:'Bearer '+process.env.RUNTIME_TOKEN}:{}; const r=await fetch(process.env.RUNTIME_URL,{method:'POST',headers:h}); process.exit(r.ok?0:1);"
|
|
728
|
+
local wait_count=0
|
|
729
|
+
while runtime_health_check && (( wait_count < 40 )); do
|
|
730
|
+
sleep 0.1
|
|
731
|
+
(( wait_count++ )) || true
|
|
732
|
+
done
|
|
733
|
+
rm -f "$pid_file"
|
|
734
|
+
printf 'agent-runtime stopped\n'
|
|
735
|
+
return
|
|
736
|
+
fi
|
|
690
737
|
if ! runtime_pid_running; then
|
|
691
738
|
printf 'agent-runtime is not running from %s\n' "$pid_file"
|
|
692
739
|
rm -f "$pid_file"
|
|
@@ -731,6 +778,23 @@ runtime_command() {
|
|
|
731
778
|
esac
|
|
732
779
|
}
|
|
733
780
|
|
|
781
|
+
refresh_project() {
|
|
782
|
+
printf 'Stopping shared runtime...\n'
|
|
783
|
+
runtime_command down
|
|
784
|
+
|
|
785
|
+
local entry workspace
|
|
786
|
+
for entry in "$WORKSPACES_DIR"/*; do
|
|
787
|
+
[[ -f "$entry/.env" ]] || continue
|
|
788
|
+
workspace="$(basename "$entry")"
|
|
789
|
+
printf 'Removing workspace stack and images: %s\n' "$workspace"
|
|
790
|
+
compose_for_workspace "$workspace" down --rmi all --remove-orphans
|
|
791
|
+
done
|
|
792
|
+
|
|
793
|
+
printf 'Removing external-agent stack and images...\n'
|
|
794
|
+
agents_compose purge
|
|
795
|
+
printf 'Refresh complete. Persistent workspace and agent data were preserved.\n'
|
|
796
|
+
}
|
|
797
|
+
|
|
734
798
|
ensure_runtime_up() {
|
|
735
799
|
if [[ "${WIKI_MANAGER_RUNTIME_AUTOSTART:-1}" == "0" ]]; then
|
|
736
800
|
return
|
|
@@ -1214,6 +1278,11 @@ main() {
|
|
|
1214
1278
|
exit 0
|
|
1215
1279
|
fi
|
|
1216
1280
|
|
|
1281
|
+
if [[ $# -eq 1 && "$1" == "refresh" ]]; then
|
|
1282
|
+
refresh_project
|
|
1283
|
+
exit 0
|
|
1284
|
+
fi
|
|
1285
|
+
|
|
1217
1286
|
# config <workspace> [path]
|
|
1218
1287
|
if [[ $# -ge 2 && "$1" == "config" ]]; then
|
|
1219
1288
|
local cfg_workspace="$2"
|