niahere 0.2.38 → 0.2.41
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/package.json +1 -1
- package/src/commands/service.ts +4 -2
- package/src/core/alive.ts +81 -2
- package/src/core/daemon.ts +9 -6
- package/src/core/runner.ts +8 -4
- package/src/db/models/message.ts +40 -1
- package/src/db/models/session.ts +29 -0
- package/src/mcp/server.ts +33 -0
- package/src/mcp/tools.ts +18 -0
- package/src/prompts/environment.md +13 -0
- package/src/types/index.ts +1 -1
- package/src/types/message.ts +16 -0
package/package.json
CHANGED
package/src/commands/service.ts
CHANGED
|
@@ -48,6 +48,8 @@ function buildPlist(): string {
|
|
|
48
48
|
<key>SuccessfulExit</key>
|
|
49
49
|
<false/>
|
|
50
50
|
</dict>
|
|
51
|
+
<key>ThrottleInterval</key>
|
|
52
|
+
<integer>10</integer>
|
|
51
53
|
<key>StandardOutPath</key>
|
|
52
54
|
<string>${paths.daemonLog}</string>
|
|
53
55
|
<key>StandardErrorPath</key>
|
|
@@ -86,10 +88,10 @@ async function uninstallLaunchd(): Promise<void> {
|
|
|
86
88
|
const path = plistPath();
|
|
87
89
|
if (!existsSync(path)) return;
|
|
88
90
|
|
|
91
|
+
// Unload to stop the process and disable KeepAlive respawn.
|
|
92
|
+
// Keep the plist file so RunAtLoad starts the daemon on next login.
|
|
89
93
|
const unload = Bun.spawn(["launchctl", "unload", path], { stdout: "pipe", stderr: "pipe" });
|
|
90
94
|
await unload.exited;
|
|
91
|
-
|
|
92
|
-
try { unlinkSync(path); } catch { /* already gone */ }
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
function isLaunchdInstalled(): boolean {
|
package/src/core/alive.ts
CHANGED
|
@@ -4,11 +4,72 @@ import { getSql, closeDb } from "../db/connection";
|
|
|
4
4
|
import { getFailures, type Check } from "./health";
|
|
5
5
|
|
|
6
6
|
const HEARTBEAT_INTERVAL = 60_000; // 60s
|
|
7
|
+
const PG_DATA_DIRS = [
|
|
8
|
+
"/opt/homebrew/var/postgresql@18",
|
|
9
|
+
"/opt/homebrew/var/postgresql@17",
|
|
10
|
+
"/opt/homebrew/var/postgres",
|
|
11
|
+
];
|
|
7
12
|
|
|
8
13
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
9
14
|
let lastFailures: string[] = [];
|
|
10
15
|
let recoveryAttempted = false;
|
|
11
16
|
|
|
17
|
+
/** Deterministic Postgres recovery: remove stale PID file + restart service. */
|
|
18
|
+
async function recoverPostgres(): Promise<boolean> {
|
|
19
|
+
const ready = Bun.spawnSync(["pg_isready"]);
|
|
20
|
+
if (ready.exitCode === 0) return true; // already up
|
|
21
|
+
|
|
22
|
+
log.info("alive: postgres not ready, attempting deterministic recovery");
|
|
23
|
+
|
|
24
|
+
// Find and remove stale postmaster.pid
|
|
25
|
+
const { existsSync, unlinkSync, readFileSync } = await import("fs");
|
|
26
|
+
for (const dir of PG_DATA_DIRS) {
|
|
27
|
+
const pidFile = `${dir}/postmaster.pid`;
|
|
28
|
+
if (!existsSync(pidFile)) continue;
|
|
29
|
+
|
|
30
|
+
// Read the PID from line 1 and check if it's actually a postgres process
|
|
31
|
+
try {
|
|
32
|
+
const pid = parseInt(readFileSync(pidFile, "utf8").split("\n")[0], 10);
|
|
33
|
+
if (!isNaN(pid)) {
|
|
34
|
+
const check = Bun.spawnSync(["ps", "-p", String(pid), "-o", "comm="]);
|
|
35
|
+
const comm = new TextDecoder().decode(check.stdout).trim();
|
|
36
|
+
if (check.exitCode !== 0 || !comm.includes("postgres")) {
|
|
37
|
+
log.info({ pidFile, stalePid: pid, actualProcess: comm || "dead" }, "alive: removing stale postmaster.pid");
|
|
38
|
+
unlinkSync(pidFile);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
log.warn({ err, pidFile }, "alive: could not inspect postmaster.pid");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Restart the service
|
|
47
|
+
if (process.platform === "darwin") {
|
|
48
|
+
// Try common brew postgresql service names
|
|
49
|
+
for (const svc of ["postgresql@18", "postgresql@17", "postgresql"]) {
|
|
50
|
+
const result = Bun.spawnSync(["brew", "services", "start", svc]);
|
|
51
|
+
if (result.exitCode === 0) {
|
|
52
|
+
log.info({ service: svc }, "alive: brew service start issued");
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
Bun.spawnSync(["systemctl", "start", "postgresql"]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Wait briefly for postgres to come up
|
|
61
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
62
|
+
|
|
63
|
+
const check = Bun.spawnSync(["pg_isready"]);
|
|
64
|
+
if (check.exitCode === 0) {
|
|
65
|
+
log.info("alive: postgres recovered via deterministic fix");
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
log.warn("alive: deterministic postgres recovery failed");
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
12
73
|
async function attemptDbReconnect(): Promise<boolean> {
|
|
13
74
|
try {
|
|
14
75
|
await closeDb();
|
|
@@ -138,10 +199,28 @@ async function heartbeat(): Promise<void> {
|
|
|
138
199
|
}
|
|
139
200
|
}
|
|
140
201
|
|
|
141
|
-
//
|
|
202
|
+
// Deterministic postgres recovery before LLM agent
|
|
203
|
+
if (failureNames.includes("database") && !recoveryAttempted) {
|
|
204
|
+
const pgFixed = await recoverPostgres();
|
|
205
|
+
if (pgFixed) {
|
|
206
|
+
const reconnected = await attemptDbReconnect();
|
|
207
|
+
if (reconnected) {
|
|
208
|
+
const remaining = await getFailures();
|
|
209
|
+
if (remaining.length === 0) {
|
|
210
|
+
log.info("alive: postgres recovered (deterministic fix, no LLM needed)");
|
|
211
|
+
await notifyUser("Postgres was down (stale PID). Fixed automatically — no LLM agent needed.");
|
|
212
|
+
lastFailures = [];
|
|
213
|
+
recoveryAttempted = false;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Run LLM recovery agent once per outage (fallback for non-trivial issues)
|
|
142
221
|
if (!recoveryAttempted) {
|
|
143
222
|
recoveryAttempted = true;
|
|
144
|
-
log.info({ failures: failureNames }, "alive: running recovery agent");
|
|
223
|
+
log.info({ failures: failureNames }, "alive: running LLM recovery agent");
|
|
145
224
|
|
|
146
225
|
const { recovered, report } = await runRecoveryAgent(failures);
|
|
147
226
|
|
package/src/core/daemon.ts
CHANGED
|
@@ -121,7 +121,7 @@ function waitForExit(timeoutMs: number): void {
|
|
|
121
121
|
/** Return PIDs of running daemon processes (excluding ourselves). */
|
|
122
122
|
export function findDaemonPids(): number[] {
|
|
123
123
|
try {
|
|
124
|
-
const result = Bun.spawnSync(["pgrep", "-f", "
|
|
124
|
+
const result = Bun.spawnSync(["pgrep", "-f", "src/cli\\.ts run$"]);
|
|
125
125
|
const stdout = new TextDecoder().decode(result.stdout).trim();
|
|
126
126
|
if (!stdout) return [];
|
|
127
127
|
return stdout.split("\n")
|
|
@@ -150,16 +150,19 @@ export async function runDaemon(): Promise<void> {
|
|
|
150
150
|
delete process.env.CLAUDE_CODE_ENTRYPOINT;
|
|
151
151
|
delete process.env.CLAUDE_AGENT_SDK_VERSION;
|
|
152
152
|
|
|
153
|
-
// Startup guard: if another daemon is alive, exit immediately
|
|
153
|
+
// Startup guard: if another nia daemon is alive, exit immediately.
|
|
154
|
+
// Use pgrep (via findDaemonPids) instead of kill(pid,0) to verify the
|
|
155
|
+
// PID is actually a nia process — not a recycled OS PID from something else.
|
|
154
156
|
const existingPid = readPid();
|
|
155
157
|
if (existingPid !== null && existingPid !== process.pid) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
const aliveDaemons = findDaemonPids();
|
|
159
|
+
if (aliveDaemons.includes(existingPid)) {
|
|
158
160
|
log.debug({ existingPid, myPid: process.pid }, "another daemon is already running, exiting");
|
|
159
161
|
process.exit(0);
|
|
160
|
-
} catch {
|
|
161
|
-
// Dead PID in pidfile — safe to take over
|
|
162
162
|
}
|
|
163
|
+
// PID in file is stale (dead or recycled by OS) — safe to take over
|
|
164
|
+
log.warn({ stalePid: existingPid }, "taking over from stale pid");
|
|
165
|
+
removePid();
|
|
163
166
|
}
|
|
164
167
|
|
|
165
168
|
// Crash handlers — ensure PID cleanup and logging on unhandled errors.
|
package/src/core/runner.ts
CHANGED
|
@@ -247,13 +247,15 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
247
247
|
};
|
|
248
248
|
appendAudit(auditEntry);
|
|
249
249
|
|
|
250
|
-
state
|
|
250
|
+
// Re-read state to avoid clobbering concurrent job updates
|
|
251
|
+
const freshState = { ...readState() };
|
|
252
|
+
freshState[job.name] = {
|
|
251
253
|
lastRun: timestamp,
|
|
252
254
|
status: result.status,
|
|
253
255
|
duration_ms: result.duration_ms,
|
|
254
256
|
error: result.error,
|
|
255
257
|
};
|
|
256
|
-
writeState(
|
|
258
|
+
writeState(freshState);
|
|
257
259
|
|
|
258
260
|
return result;
|
|
259
261
|
} catch (err) {
|
|
@@ -278,13 +280,15 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
278
280
|
error: errorMsg,
|
|
279
281
|
});
|
|
280
282
|
|
|
281
|
-
state
|
|
283
|
+
// Re-read state to avoid clobbering concurrent job updates
|
|
284
|
+
const freshState = { ...readState() };
|
|
285
|
+
freshState[job.name] = {
|
|
282
286
|
lastRun: timestamp,
|
|
283
287
|
status: "error",
|
|
284
288
|
duration_ms,
|
|
285
289
|
error: errorMsg,
|
|
286
290
|
};
|
|
287
|
-
writeState(
|
|
291
|
+
writeState(freshState);
|
|
288
292
|
|
|
289
293
|
return result;
|
|
290
294
|
}
|
package/src/db/models/message.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getSql } from "../connection";
|
|
2
|
-
import type { SaveMessageParams, RoomStats, RecentMessage } from "../../types";
|
|
2
|
+
import type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "../../types";
|
|
3
3
|
|
|
4
4
|
export async function save(params: SaveMessageParams): Promise<void> {
|
|
5
5
|
const sql = getSql();
|
|
@@ -29,6 +29,45 @@ export async function getRecent(limit = 20, room?: string): Promise<RecentMessag
|
|
|
29
29
|
}));
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export async function search(query: string, limit = 20, room?: string): Promise<SearchResult[]> {
|
|
33
|
+
const sql = getSql();
|
|
34
|
+
const pattern = `%${query}%`;
|
|
35
|
+
const rows = room
|
|
36
|
+
? await sql`
|
|
37
|
+
SELECT session_id, room, sender, content, created_at
|
|
38
|
+
FROM messages WHERE content ILIKE ${pattern} AND room = ${room}
|
|
39
|
+
ORDER BY created_at DESC LIMIT ${limit}
|
|
40
|
+
`
|
|
41
|
+
: await sql`
|
|
42
|
+
SELECT session_id, room, sender, content, created_at
|
|
43
|
+
FROM messages WHERE content ILIKE ${pattern}
|
|
44
|
+
ORDER BY created_at DESC LIMIT ${limit}
|
|
45
|
+
`;
|
|
46
|
+
return rows.map((r) => ({
|
|
47
|
+
sessionId: r.session_id,
|
|
48
|
+
room: r.room,
|
|
49
|
+
sender: r.sender,
|
|
50
|
+
content: r.content,
|
|
51
|
+
createdAt: String(r.created_at),
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function getBySession(sessionId: string): Promise<SessionMessage[]> {
|
|
56
|
+
const sql = getSql();
|
|
57
|
+
const rows = await sql`
|
|
58
|
+
SELECT room, sender, content, is_from_agent, created_at
|
|
59
|
+
FROM messages WHERE session_id = ${sessionId}
|
|
60
|
+
ORDER BY created_at ASC
|
|
61
|
+
`;
|
|
62
|
+
return rows.map((r) => ({
|
|
63
|
+
room: r.room,
|
|
64
|
+
sender: r.sender,
|
|
65
|
+
content: r.content,
|
|
66
|
+
isFromAgent: r.is_from_agent,
|
|
67
|
+
createdAt: String(r.created_at),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
32
71
|
export async function getRoomStats(): Promise<RoomStats[]> {
|
|
33
72
|
const sql = getSql();
|
|
34
73
|
const rows = await sql`
|
package/src/db/models/session.ts
CHANGED
|
@@ -49,6 +49,35 @@ export async function getRecent(room: string, limit = 10): Promise<SessionSummar
|
|
|
49
49
|
}));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export async function listRecent(limit = 10, room?: string): Promise<SessionSummary[]> {
|
|
53
|
+
if (room) return getRecent(room, limit);
|
|
54
|
+
const sql = getSql();
|
|
55
|
+
const rows = await sql`
|
|
56
|
+
SELECT
|
|
57
|
+
s.id,
|
|
58
|
+
s.room,
|
|
59
|
+
s.created_at,
|
|
60
|
+
s.updated_at,
|
|
61
|
+
(
|
|
62
|
+
SELECT content FROM messages m
|
|
63
|
+
WHERE m.session_id = s.id AND m.sender = 'user'
|
|
64
|
+
ORDER BY m.created_at ASC LIMIT 1
|
|
65
|
+
) AS preview,
|
|
66
|
+
(SELECT COUNT(*)::int FROM messages m WHERE m.session_id = s.id) AS message_count
|
|
67
|
+
FROM sessions s
|
|
68
|
+
ORDER BY s.updated_at DESC
|
|
69
|
+
LIMIT ${limit}
|
|
70
|
+
`;
|
|
71
|
+
return rows.map((r) => ({
|
|
72
|
+
id: r.id,
|
|
73
|
+
room: r.room,
|
|
74
|
+
createdAt: String(r.created_at),
|
|
75
|
+
updatedAt: String(r.updated_at),
|
|
76
|
+
preview: r.preview ? String(r.preview) : null,
|
|
77
|
+
messageCount: r.message_count,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
export async function create(id: string, room: string): Promise<void> {
|
|
53
82
|
const sql = getSql();
|
|
54
83
|
await sql`INSERT INTO sessions (id, room) VALUES (${id}, ${room})`;
|
package/src/mcp/server.ts
CHANGED
|
@@ -99,6 +99,39 @@ export function createNiaMcpServer() {
|
|
|
99
99
|
content: [{ type: "text" as const, text: await handlers.listMessages(args.limit, args.room) }],
|
|
100
100
|
}),
|
|
101
101
|
),
|
|
102
|
+
tool(
|
|
103
|
+
"list_sessions",
|
|
104
|
+
"Browse past conversation sessions with previews. Returns session IDs you can pass to read_session.",
|
|
105
|
+
{
|
|
106
|
+
room: z.string().optional().describe("Filter by room name"),
|
|
107
|
+
limit: z.number().default(10).describe("Number of sessions to return"),
|
|
108
|
+
},
|
|
109
|
+
async (args) => ({
|
|
110
|
+
content: [{ type: "text" as const, text: await handlers.listSessions(args.limit, args.room) }],
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
113
|
+
tool(
|
|
114
|
+
"search_messages",
|
|
115
|
+
"Search across all past messages by keyword. Returns matching messages with session IDs for deeper reading.",
|
|
116
|
+
{
|
|
117
|
+
query: z.string().describe("Text to search for in message content"),
|
|
118
|
+
room: z.string().optional().describe("Filter by room name"),
|
|
119
|
+
limit: z.number().default(20).describe("Max results to return"),
|
|
120
|
+
},
|
|
121
|
+
async (args) => ({
|
|
122
|
+
content: [{ type: "text" as const, text: await handlers.searchMessages(args.query, args.limit, args.room) }],
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
tool(
|
|
126
|
+
"read_session",
|
|
127
|
+
"Load the full transcript of a specific conversation session. Use list_sessions or search_messages to find session IDs.",
|
|
128
|
+
{
|
|
129
|
+
session_id: z.string().describe("Session ID to read"),
|
|
130
|
+
},
|
|
131
|
+
async (args) => ({
|
|
132
|
+
content: [{ type: "text" as const, text: await handlers.readSession(args.session_id) }],
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
102
135
|
tool(
|
|
103
136
|
"add_watch_channel",
|
|
104
137
|
"Add or update a Slack watch channel. Watch channels receive ALL messages (not just @mentions) and act based on the behavior prompt. Takes effect on next message (hot-reloads).",
|
package/src/mcp/tools.ts
CHANGED
|
@@ -244,6 +244,24 @@ export async function listMessages(limit = 20, room?: string): Promise<string> {
|
|
|
244
244
|
return JSON.stringify(messages, null, 2);
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
export async function listSessions(limit = 10, room?: string): Promise<string> {
|
|
248
|
+
const sessions = await Session.listRecent(limit, room);
|
|
249
|
+
if (sessions.length === 0) return "No sessions found.";
|
|
250
|
+
return JSON.stringify(sessions, null, 2);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function searchMessages(query: string, limit = 20, room?: string): Promise<string> {
|
|
254
|
+
const results = await Message.search(query, limit, room);
|
|
255
|
+
if (results.length === 0) return "No matching messages found.";
|
|
256
|
+
return JSON.stringify(results, null, 2);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function readSession(sessionId: string): Promise<string> {
|
|
260
|
+
const messages = await Message.getBySession(sessionId);
|
|
261
|
+
if (messages.length === 0) return "Session not found or has no messages.";
|
|
262
|
+
return JSON.stringify(messages, null, 2);
|
|
263
|
+
}
|
|
264
|
+
|
|
247
265
|
export function addRule(rule: string): string {
|
|
248
266
|
const { selfDir } = getPaths();
|
|
249
267
|
const rulesPath = join(selfDir, "rules.md");
|
|
@@ -31,6 +31,9 @@ You have MCP tools for managing jobs directly (preferred over CLI for speed):
|
|
|
31
31
|
- **run_job** — trigger a job to run immediately
|
|
32
32
|
- **send_message** — send a message to the user (via telegram, slack, or default channel). Supports `media_path` to send images/files.
|
|
33
33
|
- **list_messages** — read recent chat history
|
|
34
|
+
- **list_sessions** — browse past conversation sessions with previews and message counts. Returns session IDs.
|
|
35
|
+
- **search_messages** — keyword search across all past messages. Find when something was discussed.
|
|
36
|
+
- **read_session** — load the full transcript of a specific session by ID.
|
|
34
37
|
- **add_watch_channel** — add a Slack channel for proactive monitoring. Specify channel key (`channel_id#name`) and behavior prompt. Hot-reloads.
|
|
35
38
|
- **remove_watch_channel** — stop watching a Slack channel. Hot-reloads.
|
|
36
39
|
- **enable_watch_channel** / **disable_watch_channel** — toggle a watch channel on/off without removing it. Hot-reloads.
|
|
@@ -73,6 +76,16 @@ Config reference:
|
|
|
73
76
|
- `channels.slack.watch` — per-channel proactive monitoring. Keys are `channel_id#channel_name` format.
|
|
74
77
|
{{slackWatch}}
|
|
75
78
|
|
|
79
|
+
## Conversation History
|
|
80
|
+
|
|
81
|
+
You have access to all prior conversations stored in the database:
|
|
82
|
+
|
|
83
|
+
- **list_sessions** — browse past sessions (with previews and message counts). Use to find a conversation.
|
|
84
|
+
- **search_messages** — search across all past messages by keyword. Returns session IDs for deeper reading.
|
|
85
|
+
- **read_session** — load the full transcript of a session by ID.
|
|
86
|
+
|
|
87
|
+
Use these when the user asks "did we talk about...", "what did I say about...", or when you need context from a prior conversation. Combine with `read_memory` for a complete picture.
|
|
88
|
+
|
|
76
89
|
## Persona & Memory
|
|
77
90
|
|
|
78
91
|
Your persona files live in {{selfDir}}/:
|
package/src/types/index.ts
CHANGED
|
@@ -7,5 +7,5 @@ export type { Channel, ChannelFactory } from "./channel";
|
|
|
7
7
|
export type { ChatState } from "./chat-state";
|
|
8
8
|
export type { Config, ChannelsConfig, TelegramConfig, SlackConfig } from "./config";
|
|
9
9
|
export type { Paths } from "./paths";
|
|
10
|
-
export type { SaveMessageParams, RoomStats, RecentMessage } from "./message";
|
|
10
|
+
export type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "./message";
|
|
11
11
|
export type { AgentInfo } from "./agent";
|
package/src/types/message.ts
CHANGED
|
@@ -19,3 +19,19 @@ export interface RecentMessage {
|
|
|
19
19
|
content: string;
|
|
20
20
|
createdAt: string;
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
export interface SearchResult {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
room: string;
|
|
26
|
+
sender: string;
|
|
27
|
+
content: string;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SessionMessage {
|
|
32
|
+
room: string;
|
|
33
|
+
sender: string;
|
|
34
|
+
content: string;
|
|
35
|
+
isFromAgent: boolean;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
}
|